Skip to content

Commit

Permalink
New: .splitByIndex and .splitOption for vars
Browse files Browse the repository at this point in the history
  • Loading branch information
phfroidmont authored and raquo committed Nov 9, 2024
1 parent b0fa6a1 commit 5da5634
Show file tree
Hide file tree
Showing 5 changed files with 349 additions and 1 deletion.
59 changes: 59 additions & 0 deletions src/main/scala/com/raquo/airstream/extensions/OptionVar.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.raquo.airstream.extensions

import com.raquo.airstream.core.Signal
import com.raquo.airstream.state.{LazyDerivedVar, LazyStrictSignal, Var}
import com.raquo.airstream.split.DuplicateKeysConfig

class OptionVar[A](val v: Var[Option[A]]) extends AnyVal {

/** This `.split`-s a Var of an Option by the Option's `isDefined` property.
* If you want a different key, use the .split operator directly.
*
* @param project - (initialInput, varOfInput) => output
* `project` is called whenever the parent var switches from `None` to `Some()`.
* `varOfInput` starts with `initialInput` value, and updates when
* the parent var updates from `Some(a)` to `Some(b)`.
* @param ifEmpty - returned if Option is empty. Evaluated whenever the parent var
* switches from `Some(a)` to `None`, or when the parent var
* starts with a `None`. `ifEmpty` is NOT re-evaluated when the
* parent var emits `None` if its value is already `None`.
*/
def splitOption[B](
project: (A, Var[A]) => B,
ifEmpty: => B
): Signal[B] = {
// Note: We never have duplicate keys here, so we can use
// DuplicateKeysConfig.noWarnings to improve performance
v.signal
.distinctByFn((prev, next) => prev.isEmpty && next.isEmpty) // Ignore consecutive `None` events
.split(
key = _ => (),
duplicateKeys = DuplicateKeysConfig.noWarnings
)(
(_, initial, signal) => {
val displayNameSuffix = s".splitOption(Some)"
val childVar = new LazyDerivedVar[Option[A], A](
parent = v,
signal = new LazyStrictSignal[A, A](
signal, identity, signal.displayName, displayNameSuffix + ".signal"
),
zoomOut = (inputs, newInput) => {
Some(newInput)
},
displayNameSuffix = displayNameSuffix
)
project(initial, childVar)
}
)
.map(_.getOrElse(ifEmpty))
}

def splitOption[B](
project: (A, Var[A]) => B
): Signal[Option[B]] = {
splitOption(
(initial, someVar) => Some(project(initial, someVar)),
ifEmpty = None
)
}
}
12 changes: 12 additions & 0 deletions src/main/scala/com/raquo/airstream/split/Splittable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ trait Splittable[M[_]] {
})
}

def findUpdateByIndex[A](inputs: M[A], index: Int, newItem: A): M[A] = {
var ix = -1
map(inputs, (input: A) => {
ix += 1
if (ix == index) {
newItem
} else {
input
}
})
}

def zipWithIndex[A](inputs: M[A]): M[(A, Int)] = {
var ix = -1
map(inputs, (input: A) => {
Expand Down
30 changes: 30 additions & 0 deletions src/main/scala/com/raquo/airstream/split/SplittableVar.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ class SplittableVar[M[_], Input](val v: Var[M[Input]]) extends AnyVal {
)
}

/** Like `split`, but uses index of the item in the list as the key. */
def splitByIndex[Output](
project: (Int, Input, Var[Input]) => Output
) (
implicit splittable: Splittable[M]
): Signal[M[Output]] = {
new SplitSignal[M, (Input, Int), Output, Int](
parent = v.signal.map(splittable.zipWithIndex),
key = _._2, // Index
distinctCompose = _.distinctBy(_._1),
project = (index: Int, initialTuple, tupleSignal) => {
val displayNameSuffix = s".splitByIndex(index = ${index})"
val childVar = new LazyDerivedVar[M[Input], Input](
parent = v,
signal = new LazyStrictSignal[Input, Input](
tupleSignal.map(_._1), identity, tupleSignal.displayName, displayNameSuffix + ".signal"
),
zoomOut = (inputs, newInput) => {
splittable.findUpdateByIndex(inputs, index, newInput)
},
displayNameSuffix = displayNameSuffix
)
project(index, initialTuple._1, childVar)
},
splittable,
DuplicateKeysConfig.noWarnings, // No need to check for duplicates – we know the keys are good.?
strict = true
)
}

/** This variation of the `split` operator is designed for Var-s of
* mutable collections. It works like the usual split, except that
* it updates the mutable collection in-place instead of creating
Expand Down
5 changes: 5 additions & 0 deletions src/main/scala/com/raquo/airstream/state/Var.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.raquo.airstream.state
import com.raquo.airstream.core.AirstreamError.VarError
import com.raquo.airstream.core.Source.SignalSource
import com.raquo.airstream.core.{AirstreamError, Named, Observer, Signal, Sink, Transaction}
import com.raquo.airstream.extensions.OptionVar
import com.raquo.airstream.ownership.Owner
import com.raquo.airstream.split.SplittableVar
import com.raquo.airstream.util.hasDuplicateTupleKeys
Expand Down Expand Up @@ -278,5 +279,9 @@ object Var {

/** Provides methods on Var: split, splitMutate */
implicit def toSplittableVar[M[_], Input](signal: Var[M[Input]]): SplittableVar[M, Input] = new SplittableVar(signal)

/** Provides methods on Var: splitOption */
implicit def toOptionVar[A](v: Var[Option[A]]): OptionVar[A] = new OptionVar(v)

}

244 changes: 243 additions & 1 deletion src/test/scala/com/raquo/airstream/misc/SplitVarSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import com.raquo.airstream.UnitSpec
import com.raquo.airstream.core.{Observer, Signal, Transaction}
import com.raquo.airstream.eventbus.EventBus
import com.raquo.airstream.fixtures.{Effect, TestableOwner}
import com.raquo.airstream.ownership.{DynamicOwner, DynamicSubscription, ManualOwner}
import com.raquo.airstream.ownership.{DynamicOwner, DynamicSubscription, ManualOwner, Subscription}
import com.raquo.airstream.split.DuplicateKeysConfig
import com.raquo.airstream.state.Var
import com.raquo.ew.JsArray
Expand Down Expand Up @@ -712,4 +712,246 @@ class SplitVarSpec extends UnitSpec with BeforeAndAfter {

//effects.clear()
}

it("splitByIndex var - quick check") {
withOrWithoutDuplicateKeyWarnings {
val effects = mutable.Buffer[Effect[String]]()

val myVar = Var[List[Foo]](Foo("initial", 1) :: Nil)

val owner = new TestableOwner

// #Note: `identity` here means we're not using `distinct` to filter out redundancies in fooSignal
// We test like this to make sure that the underlying splitting machinery works correctly without this crutch
val signal = myVar.splitByIndex((index, initialFoo, fooVar) => {
effects += Effect(s"init-child-$index", initialFoo.id + "-" + initialFoo.version.toString)
fooVar.signal.foreach { foo =>
effects += Effect(s"update-child-$index", foo.id + "-" + foo.version.toString)
}(owner)
Bar(index.toString)
})

signal.foreach { result =>
effects += Effect("result", result.toString)
}(owner)

effects shouldBe mutable.Buffer(
Effect("init-child-0", "initial-1"),
Effect("update-child-0", "initial-1"),
Effect("result", "List(Bar(0))")
)

effects.clear()

// --

myVar.writer.onNext(Foo("a", 1) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0))"),
Effect("update-child-0", "a-1"),
)

effects.clear()

// --

myVar.writer.onNext(Foo("a", 2) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0))"),
Effect("update-child-0", "a-2")
)

effects.clear()

// --

myVar.writer.onNext(Foo("a", 3) :: Foo("b", 1) :: Nil)

effects shouldBe mutable.Buffer(
Effect("init-child-1", "b-1"),
Effect("update-child-1", "b-1"),
Effect("result", "List(Bar(0), Bar(1))"),
Effect("update-child-0", "a-3")
)

effects.clear()

// --

myVar.writer.onNext(Foo("b", 1) :: Foo("a", 3) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0), Bar(1))"),
Effect("update-child-0", "b-1"),
Effect("update-child-1", "a-3")
)

effects.clear()

// --

myVar.writer.onNext(Foo("b", 1) :: Foo("a", 4) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0), Bar(1))"),
Effect("update-child-1", "a-4")
)

effects.clear()

// --

myVar.writer.onNext(Foo("b", 2) :: Foo("a", 4) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0), Bar(1))"),
Effect("update-child-0", "b-2")
)

effects.clear()

// --

myVar.writer.onNext(Foo("b", 3) :: Foo("a", 5) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0), Bar(1))"),
Effect("update-child-0", "b-3"),
Effect("update-child-1", "a-5")
)

effects.clear()

// --

myVar.writer.onNext(Foo("b", 4) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0))"),
Effect("update-child-0", "b-4")
)

effects.clear()

// --

myVar.writer.onNext(Foo("b", 4) :: Nil)

effects shouldBe mutable.Buffer(
Effect("result", "List(Bar(0))")
)

//effects.clear()
}
}

it("splitOption var") {
withOrWithoutDuplicateKeyWarnings {
val effects = mutable.Buffer[Effect[String]]()

val myVar = Var[Option[Foo]](Some(Foo("initial", 1)))

val owner = new TestableOwner

var maybeLastSub: Option[Subscription] = None

// #Note: `identity` here means we're not using `distinct` to filter out redundancies in fooSignal
// We test like this to make sure that the underlying splitting machinery works correctly without this crutch
val signal = myVar.splitOption(
(initialFoo, fooVar) => {
val initialKey = s"${initialFoo.id}-${initialFoo.version}"
effects += Effect(s"init-child-$initialKey", initialKey)
// #Note: this manual management isn't great, but we don't have Laminar's mounting system here
maybeLastSub.foreach(_.kill())
maybeLastSub = Some(fooVar.signal.foreach { foo =>
val updatedKey = s"${foo.id}-${foo.version}"
effects += Effect(s"update-child-$updatedKey", updatedKey)
}(owner))
Bar(initialKey)
},
ifEmpty = {
effects += Effect("ifEmpty-eval", "")
Bar("empty")
}
)

// --

signal.foreach { result =>
effects += Effect("result", result.toString)
}(owner)

effects shouldBe mutable.Buffer(
Effect("init-child-initial-1", "initial-1"),
Effect("update-child-initial-1", "initial-1"),
Effect("result", "Bar(initial-1)")
)

effects.clear()

// --

myVar.writer.onNext(Some(Foo("a", 1)))

effects shouldBe mutable.Buffer(
Effect("result", "Bar(initial-1)"), // we use initialKey when returning Bar, so it's `initial-1`, not `a-1`
Effect("update-child-a-1", "a-1")
)

effects.clear()

// --

myVar.writer.onNext(Some(Foo("a", 2)))

effects shouldBe mutable.Buffer(
Effect("result", "Bar(initial-1)"), // we use initialKey when returning Bar, so it's `initial-1`, not `a-2`
Effect("update-child-a-2", "a-2")
)

effects.clear()

// --

myVar.writer.onNext(None)

effects shouldBe mutable.Buffer(
Effect("ifEmpty-eval", ""),
Effect("result", "Bar(empty)")
)

effects.clear()

// --

myVar.writer.onNext(None)

effects shouldBe mutable.Buffer()

// --

myVar.writer.onNext(Some(Foo("c", 1)))

effects shouldBe mutable.Buffer(
Effect("init-child-c-1", "c-1"),
Effect("update-child-c-1", "c-1"),
Effect("result", "Bar(c-1)")
)

effects.clear()

// --

myVar.writer.onNext(Some(Foo("c", 2)))

effects shouldBe mutable.Buffer(
Effect("result", "Bar(c-1)"), // we use initialKey when returning Bar, so it's `c-1`, not `c-2`
Effect("update-child-c-2", "c-2")
)

// effects.clear()
}
}
}

0 comments on commit 5da5634

Please sign in to comment.