diff --git a/src/main/scala/com/raquo/airstream/combine/CombineEventStreamN.scala b/src/main/scala/com/raquo/airstream/combine/CombineEventStreamN.scala index e722eafc..850a41a0 100644 --- a/src/main/scala/com/raquo/airstream/combine/CombineEventStreamN.scala +++ b/src/main/scala/com/raquo/airstream/combine/CombineEventStreamN.scala @@ -1,15 +1,15 @@ package com.raquo.airstream.combine -import com.raquo.airstream.common.InternalParentObserver -import com.raquo.airstream.core.{EventStream, Protected, WritableEventStream} +import com.raquo.airstream.common.{InternalParentObserver, MultiParentEventStream} +import com.raquo.airstream.core.{EventStream, Protected} import scala.util.Try /** @param combinator Must not throw! */ class CombineEventStreamN[A, Out]( - parents: Seq[EventStream[A]], + override protected[this] val parents: Seq[EventStream[A]], combinator: Seq[A] => Out -) extends WritableEventStream[Out] with CombineObservable[Out] { +) extends MultiParentEventStream[A, Out] with CombineObservable[Out] { // @TODO[API] Maybe this should throw if parents.isEmpty @@ -40,9 +40,4 @@ class CombineEventStreamN[A, Out]( }: _* ) - override protected[this] def onStop(): Unit = { - maybeLastParentValues.indices.foreach(maybeLastParentValues.update(_, None)) - super.onStop() - } - } diff --git a/src/main/scala/com/raquo/airstream/combine/CombineObservable.scala b/src/main/scala/com/raquo/airstream/combine/CombineObservable.scala index 3d26ce95..3402e5d7 100644 --- a/src/main/scala/com/raquo/airstream/combine/CombineObservable.scala +++ b/src/main/scala/com/raquo/airstream/combine/CombineObservable.scala @@ -2,11 +2,11 @@ package com.raquo.airstream.combine import com.raquo.airstream.common.InternalParentObserver import com.raquo.airstream.core.AirstreamError.CombinedError -import com.raquo.airstream.core.{ SyncObservable, Transaction, WritableObservable } +import com.raquo.airstream.core.{SyncObservable, Transaction, WritableObservable} import org.scalajs.dom import scala.scalajs.js -import scala.util.{ Failure, Success, Try } +import scala.util.{Failure, Success, Try} trait CombineObservable[A] extends SyncObservable[A] { this: WritableObservable[A] => @@ -45,7 +45,7 @@ trait CombineObservable[A] extends SyncObservable[A] { this: WritableObservable[ } override protected[this] def onStart(): Unit = { - parentObservers.foreach(_.addToParent()) + parentObservers.foreach(_.addToParent(shouldCallMaybeWillStart = false)) super.onStart() } diff --git a/src/main/scala/com/raquo/airstream/combine/CombineSignalN.scala b/src/main/scala/com/raquo/airstream/combine/CombineSignalN.scala index c4437682..74542310 100644 --- a/src/main/scala/com/raquo/airstream/combine/CombineSignalN.scala +++ b/src/main/scala/com/raquo/airstream/combine/CombineSignalN.scala @@ -1,28 +1,28 @@ package com.raquo.airstream.combine -import com.raquo.airstream.common.InternalParentObserver -import com.raquo.airstream.core.{Protected, Signal, WritableSignal} +import com.raquo.airstream.common.{InternalParentObserver, MultiParentSignal} +import com.raquo.airstream.core.{Protected, Signal} import scala.util.Try /** @param combinator Must not throw! */ class CombineSignalN[A, Out]( - protected[this] val parents: Seq[Signal[A]], + override protected[this] val parents: Seq[Signal[A]], protected[this] val combinator: Seq[A] => Out -) extends WritableSignal[Out] with CombineObservable[Out] { +) extends MultiParentSignal[A, Out] with CombineObservable[Out] { // @TODO[API] Maybe this should throw if parents.isEmpty override protected val topoRank: Int = Protected.maxParentTopoRank(parents) + 1 - override protected[this] def initialValue: Try[Out] = combinedValue - override protected[this] def inputsReady: Boolean = true override protected[this] def combinedValue: Try[Out] = { CombineObservable.seqCombinator(parents.map(_.tryNow()), combinator) } + override protected def currentValueFromParent(): Try[Out] = combinedValue + parentObservers.push( parents.map { parent => InternalParentObserver.fromTry[A](parent, (_, transaction) => { diff --git a/src/main/scala/com/raquo/airstream/combine/MergeEventStream.scala b/src/main/scala/com/raquo/airstream/combine/MergeEventStream.scala index c77020d8..22b6752e 100644 --- a/src/main/scala/com/raquo/airstream/combine/MergeEventStream.scala +++ b/src/main/scala/com/raquo/airstream/combine/MergeEventStream.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.combine -import com.raquo.airstream.common.{InternalParentObserver, Observation} -import com.raquo.airstream.core.{Observable, Protected, SyncObservable, Transaction, WritableEventStream} +import com.raquo.airstream.common.{InternalParentObserver, MultiParentEventStream, Observation} +import com.raquo.airstream.core.{EventStream, Observable, Protected, SyncObservable, Transaction, WritableEventStream} import com.raquo.airstream.util.JsPriorityQueue import scala.scalajs.js @@ -14,8 +14,8 @@ import scala.scalajs.js * does not make sense, conceptually (what do you even do with their current values?). */ class MergeEventStream[A]( - parents: Iterable[Observable[A]] -) extends WritableEventStream[A] with SyncObservable[A] { + override protected[this] val parents: Seq[EventStream[A]], +) extends WritableEventStream[A] with SyncObservable[A] with MultiParentEventStream[A, A] { override protected val topoRank: Int = Protected.maxParentTopoRank(parents) + 1 @@ -50,7 +50,7 @@ class MergeEventStream[A]( } override protected[this] def onStart(): Unit = { - parentObservers.foreach(_.addToParent()) + parentObservers.foreach(_.addToParent(shouldCallMaybeWillStart = false)) super.onStart() } diff --git a/src/main/scala/com/raquo/airstream/combine/SampleCombineEventStreamN.scala b/src/main/scala/com/raquo/airstream/combine/SampleCombineEventStreamN.scala index 398ccd7f..1608ac18 100644 --- a/src/main/scala/com/raquo/airstream/combine/SampleCombineEventStreamN.scala +++ b/src/main/scala/com/raquo/airstream/combine/SampleCombineEventStreamN.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.combine -import com.raquo.airstream.common.InternalParentObserver -import com.raquo.airstream.core.{EventStream, Protected, Signal, Transaction, WritableEventStream} +import com.raquo.airstream.common.{InternalParentObserver, MultiParentEventStream} +import com.raquo.airstream.core.{EventStream, Observable, Protected, Signal, Transaction} import scala.util.Try @@ -18,7 +18,7 @@ class SampleCombineEventStreamN[A, Out]( samplingStream: EventStream[A], sampledSignals: Seq[Signal[A]], combinator: Seq[A] => Out -) extends WritableEventStream[Out] with CombineObservable[Out] { +) extends MultiParentEventStream[A, Out] with CombineObservable[Out] { override protected val topoRank: Int = Protected.maxParentTopoRank(samplingStream +: sampledSignals) + 1 @@ -26,6 +26,8 @@ class SampleCombineEventStreamN[A, Out]( override protected[this] def inputsReady: Boolean = maybeLastSamplingValue.nonEmpty + override protected[this] val parents: Seq[Observable[A]] = samplingStream +: sampledSignals + override protected[this] def combinedValue: Try[Out] = { val parentValues = maybeLastSamplingValue.get +: sampledSignals.map(_.tryNow()) CombineObservable.seqCombinator(parentValues, combinator) diff --git a/src/main/scala/com/raquo/airstream/combine/SampleCombineSignalN.scala b/src/main/scala/com/raquo/airstream/combine/SampleCombineSignalN.scala index 8ee4a6e1..7756523a 100644 --- a/src/main/scala/com/raquo/airstream/combine/SampleCombineSignalN.scala +++ b/src/main/scala/com/raquo/airstream/combine/SampleCombineSignalN.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.combine -import com.raquo.airstream.common.InternalParentObserver -import com.raquo.airstream.core.{Protected, Signal, WritableSignal} +import com.raquo.airstream.common.{InternalParentObserver, MultiParentSignal} +import com.raquo.airstream.core.{Observable, Protected, Signal} import scala.util.Try @@ -18,19 +18,21 @@ class SampleCombineSignalN[A, Out]( samplingSignal: Signal[A], sampledSignals: Seq[Signal[A]], combinator: Seq[A] => Out -) extends WritableSignal[Out] with CombineObservable[Out] { +) extends MultiParentSignal[A, Out] with CombineObservable[Out] { override protected val topoRank: Int = Protected.maxParentTopoRank(samplingSignal +: sampledSignals) + 1 - override protected[this] def initialValue: Try[Out] = combinedValue - override protected[this] def inputsReady: Boolean = true + override protected[this] val parents: Seq[Observable[A]] = samplingSignal +: sampledSignals + override protected[this] def combinedValue: Try[Out] = { val values = (samplingSignal +: sampledSignals).map(_.tryNow()) CombineObservable.seqCombinator(values, combinator) } + override protected def currentValueFromParent(): Try[Out] = combinedValue + parentObservers.push( InternalParentObserver.fromTry[A](samplingSignal, (_, transaction) => { onInputsReady(transaction) diff --git a/src/main/scala/com/raquo/airstream/common/InternalParentObserver.scala b/src/main/scala/com/raquo/airstream/common/InternalParentObserver.scala index 0fecf546..2c996c9a 100644 --- a/src/main/scala/com/raquo/airstream/common/InternalParentObserver.scala +++ b/src/main/scala/com/raquo/airstream/common/InternalParentObserver.scala @@ -8,8 +8,8 @@ trait InternalParentObserver[A] extends InternalObserver[A] { protected[this] val parent: Observable[A] - def addToParent(): Unit = { - parent.addInternalObserver(this) + def addToParent(shouldCallMaybeWillStart: Boolean): Unit = { + parent.addInternalObserver(this, shouldCallMaybeWillStart) } def removeFromParent(): Unit = { diff --git a/src/main/scala/com/raquo/airstream/common/MultiParentEventStream.scala b/src/main/scala/com/raquo/airstream/common/MultiParentEventStream.scala new file mode 100644 index 00000000..fc1a2d16 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/common/MultiParentEventStream.scala @@ -0,0 +1,14 @@ +package com.raquo.airstream.common + +import com.raquo.airstream.core.{Observable, Protected, WritableEventStream} + +/** A simple stream that has multiple parents. */ +trait MultiParentEventStream[+I, O] extends WritableEventStream[O] { + + protected[this] val parents: Seq[Observable[I]] + + override protected def onWillStart(): Unit = { + parents.foreach(Protected.maybeWillStart) + } + +} diff --git a/src/main/scala/com/raquo/airstream/common/MultiParentSignal.scala b/src/main/scala/com/raquo/airstream/common/MultiParentSignal.scala new file mode 100644 index 00000000..06ae1c73 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/common/MultiParentSignal.scala @@ -0,0 +1,23 @@ +package com.raquo.airstream.common + +import com.raquo.airstream.core.{Observable, Protected, WritableSignal} + +import scala.util.Try + +/** A simple signal that has multiple parents. */ +trait MultiParentSignal[+I, O] extends WritableSignal[O] { + + protected[this] val parents: Seq[Observable[I]] + + override protected def onWillStart(): Unit = { + parents.foreach(Protected.maybeWillStart) + updateCurrentValueFromParent() + } + + protected def updateCurrentValueFromParent(): Try[O] = { + val nextValue = currentValueFromParent() + setCurrentValue(nextValue) + nextValue + } + +} diff --git a/src/main/scala/com/raquo/airstream/common/SingleParentEventStream.scala b/src/main/scala/com/raquo/airstream/common/SingleParentEventStream.scala new file mode 100644 index 00000000..ebc9571f --- /dev/null +++ b/src/main/scala/com/raquo/airstream/common/SingleParentEventStream.scala @@ -0,0 +1,25 @@ +package com.raquo.airstream.common + +import com.raquo.airstream.core.{InternalObserver, Observable, Protected, Transaction, WritableEventStream} + +/** A simple stream that only has one parent. */ +trait SingleParentEventStream[I, O] extends WritableEventStream[O] with InternalObserver[I] { + + protected[this] val parent: Observable[I] + + override protected def onWillStart(): Unit = { + //println(s"${this} >>>> onWillStart") + Protected.maybeWillStart(parent) + } + + override protected[this] def onStart(): Unit = { + //println(s"${this} >>>> onStart") + parent.addInternalObserver(this, shouldCallMaybeWillStart = false) + super.onStart() + } + + override protected[this] def onStop(): Unit = { + Transaction.removeInternalObserver(parent, observer = this) + super.onStop() + } +} diff --git a/src/main/scala/com/raquo/airstream/common/SingleParentObservable.scala b/src/main/scala/com/raquo/airstream/common/SingleParentObservable.scala deleted file mode 100644 index 31b29e07..00000000 --- a/src/main/scala/com/raquo/airstream/common/SingleParentObservable.scala +++ /dev/null @@ -1,19 +0,0 @@ -package com.raquo.airstream.common - -import com.raquo.airstream.core.{InternalObserver, Observable, Transaction} - -/** A simple observable that only has one parent. */ -trait SingleParentObservable[I, +O] extends Observable[O] with InternalObserver[I] { - - protected[this] val parent: Observable[I] - - override protected[this] def onStart(): Unit = { - parent.addInternalObserver(this) - super.onStart() - } - - override protected[this] def onStop(): Unit = { - Transaction.removeInternalObserver(parent, observer = this) - super.onStop() - } -} diff --git a/src/main/scala/com/raquo/airstream/common/SingleParentSignal.scala b/src/main/scala/com/raquo/airstream/common/SingleParentSignal.scala new file mode 100644 index 00000000..43b89ed4 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/common/SingleParentSignal.scala @@ -0,0 +1,39 @@ +package com.raquo.airstream.common + +import com.raquo.airstream.core.{InternalObserver, Observable, Protected, Transaction, WritableSignal} + +import scala.util.Try + +/** A simple stream that only has one parent. */ +trait SingleParentSignal[I, O] extends WritableSignal[O] with InternalObserver[I] { + + protected[this] val parent: Observable[I] + + override protected def onWillStart(): Unit = { + //println(s"${this} >>>> onWillStart") + Protected.maybeWillStart(parent) + updateCurrentValueFromParent() + } + + /** Note: this is overridden in: + * - [[com.raquo.airstream.distinct.DistinctSignal]] + * - [[com.raquo.airstream.flatten.SwitchSignal]] + */ + protected def updateCurrentValueFromParent(): Try[O] = { + //println(s"${this} >> updateCurrentValueFromParent") + val nextValue = currentValueFromParent() + setCurrentValue(nextValue) + //println(s"${this} << updateCurrentValueFromParent") + nextValue + } + + override protected[this] def onStart(): Unit = { + parent.addInternalObserver(this, shouldCallMaybeWillStart = false) + super.onStart() + } + + override protected[this] def onStop(): Unit = { + Transaction.removeInternalObserver(parent, observer = this) + super.onStop() + } +} diff --git a/src/main/scala/com/raquo/airstream/core/BaseObservable.scala b/src/main/scala/com/raquo/airstream/core/BaseObservable.scala index 0ffffd18..9d3dc0d3 100644 --- a/src/main/scala/com/raquo/airstream/core/BaseObservable.scala +++ b/src/main/scala/com/raquo/airstream/core/BaseObservable.scala @@ -4,7 +4,6 @@ import com.raquo.airstream.debug.Debugger import com.raquo.airstream.flatten.FlattenStrategy import com.raquo.airstream.ownership.{Owner, Subscription} -import scala.annotation.unused import scala.util.{Failure, Success, Try} /** This trait represents a reactive value that can be subscribed to. @@ -151,12 +150,12 @@ trait BaseObservable[+Self[+_] <: Observable[_], +A] extends Source[A] with Name protected[this] def addExternalObserver(observer: Observer[A], owner: Owner): Subscription - protected[this] def onAddedExternalObserver(@unused observer: Observer[A]): Unit + protected[this] def onAddedExternalObserver(observer: Observer[A]): Unit /** Child observable should call this method on its parents when it is started. * This observable calls [[onStart]] if this action has given it its first observer (internal or external). */ - protected[airstream] def addInternalObserver(observer: InternalObserver[A]): Unit + protected[airstream] def addInternalObserver(observer: InternalObserver[A], shouldCallMaybeWillStart: Boolean): Unit /** Child observable should call Transaction.removeInternalObserver(parent, childInternalObserver) when it is stopped. * This observable calls [[onStop]] if this action has removed its last observer (internal or external). @@ -170,6 +169,28 @@ trait BaseObservable[+Self[+_] <: Observable[_], +A] extends Source[A] with Name protected def isStarted: Boolean = numAllObservers > 0 + /** When starting an observable, this is called recursively on every one of its parents that are not started. + * This whole chain happens before onStart callback is called. This chain serves to prepare the internal states + * of observables that are about to start, e.g. you should update the signal's value to match its parent signal's + * value in this callback, if applicable. + * + * Default implementation, for observables that don't need anything, + * should be to call `parent.maybeWillStart()` for every parent observable. + * + * If custom behaviour is required, you should generally call `parent.maybeWillStart()` + * BEFORE your custom logic. Then your logic will be able to make use of parent's + * updated value. + * + * Note: THIS METHOD MUST NOT CREATE TRANSACTIONS OR FIRE ANY EVENTS! DO IT IN ONSTART IF NEEDED. + */ + protected def onWillStart(): Unit + + protected def maybeWillStart(): Unit = { + if (!isStarted) { + onWillStart() + } + } + /** This method is fired when this observable starts working (listening for parent events and/or firing its own events), * that is, when it gets its first Observer (internal or external). * @@ -184,6 +205,17 @@ trait BaseObservable[+Self[+_] <: Observable[_], +A] extends Source[A] with Name */ protected def onStop(): Unit = () + /** Airstream may internally use Scala library functions which use `==` or `hashCode` for equality, for example List.contains. + * Comparing observables by structural equality pretty much never makes sense, yet it's not that hard to run into that, all + * you need is to create a `case class` subclass, and the Scala compiler will generate a structural-equality `equals` and + * `hashCode` methods for you behind the scenes. + * + * To prevent that, we make equals and hashCode methods final, using the default implementation (which is reference equality). + */ + final override def equals(obj: Any): Boolean = super.equals(obj) + + /** Force reference equality checks. See comment for `equals`. */ + final override def hashCode(): Int = super.hashCode() } object BaseObservable { @@ -191,4 +223,8 @@ object BaseObservable { @inline private[airstream] def topoRank[O[+_] <: Observable[_]](observable: BaseObservable[O, _]): Int = { observable.topoRank } + + @inline private[airstream] def maybeWillStart[O[+_] <: Observable[_]](observable: BaseObservable[O, _]): Unit = { + observable.maybeWillStart() + } } diff --git a/src/main/scala/com/raquo/airstream/core/EventStream.scala b/src/main/scala/com/raquo/airstream/core/EventStream.scala index 1e6c73db..e3ca0b0d 100644 --- a/src/main/scala/com/raquo/airstream/core/EventStream.scala +++ b/src/main/scala/com/raquo/airstream/core/EventStream.scala @@ -85,18 +85,34 @@ trait EventStream[+A] extends Observable[A] with BaseObservable[EventStream, A] new FoldLeftSignal(parent = this, () => initial, fn) } - @inline def startWith[B >: A](initial: => B): Signal[B] = toSignal(initial) + /** @param cacheInitialValue if false, signal's initial value will be re-evaluated on every + * restart (so long as the parent stream does not emit any values) + */ + @inline def startWith[B >: A](initial: => B, cacheInitialValue: Boolean = false): Signal[B] = { + toSignal(initial, cacheInitialValue) + } - @inline def startWithTry[B >: A](initial: => Try[B]): Signal[B] = toSignalWithTry(initial) + /** @param cacheInitialValue if false, signal's initial value will be re-evaluated on every + * restart (so long as the parent stream does not emit any values) + */ + @inline def startWithTry[B >: A](initial: => Try[B], cacheInitialValue: Boolean = false): Signal[B] = { + toSignalWithTry(initial, cacheInitialValue) + } @inline def startWithNone: Signal[Option[A]] = toWeakSignal - def toSignal[B >: A](initial: => B): Signal[B] = { - toSignalWithTry(Success(initial)) + /** @param cacheInitialValue if false, signal's initial value will be re-evaluated on every + * restart (so long as the parent stream does not emit any values) + */ + def toSignal[B >: A](initial: => B, cacheInitialValue: Boolean = false): Signal[B] = { + toSignalWithTry(Success(initial), cacheInitialValue) } - def toSignalWithTry[B >: A](initial: => Try[B]): Signal[B] = { - new SignalFromEventStream(this, initial) + /** @param cacheInitialValue if false, signal's initial value will be re-evaluated on every + * restart (so long as the parent stream does not emit any values) + */ + def toSignalWithTry[B >: A](initial: => Try[B], cacheInitialValue: Boolean = false): Signal[B] = { + new SignalFromEventStream(this, initial, cacheInitialValue) } def compose[B](operator: EventStream[A] => EventStream[B]): EventStream[B] = { @@ -108,7 +124,7 @@ trait EventStream[+A] extends Observable[A] with BaseObservable[EventStream, A] * @param fn (prev, next) => isSame */ override def distinctTry(fn: (Try[A], Try[A]) => Boolean): EventStream[A] = { - new DistinctEventStream[A](parent = this, fn) + new DistinctEventStream[A](parent = this, fn, resetOnStop = false) } /** See docs for [[MapEventStream]] @@ -178,8 +194,8 @@ object EventStream { ) } - def fromFuture[A](future: Future[A], emitFutureIfCompleted: Boolean = false): EventStream[A] = { - new FutureEventStream[A](future, emitFutureIfCompleted) + def fromFuture[A](future: Future[A]): EventStream[A] = { + new FutureEventStream[A](future) } def fromJsPromise[A](promise: js.Promise[A]): EventStream[A] = { diff --git a/src/main/scala/com/raquo/airstream/core/Observable.scala b/src/main/scala/com/raquo/airstream/core/Observable.scala index 2a55702d..ea6de8f2 100644 --- a/src/main/scala/com/raquo/airstream/core/Observable.scala +++ b/src/main/scala/com/raquo/airstream/core/Observable.scala @@ -2,9 +2,7 @@ package com.raquo.airstream.core import com.raquo.airstream.debug.DebuggableObservable import com.raquo.airstream.flatten.FlattenStrategy -import com.raquo.airstream.flatten.FlattenStrategy.{SwitchFutureStrategy, SwitchSignalStrategy, SwitchStreamStrategy} - -import scala.concurrent.Future +import com.raquo.airstream.flatten.FlattenStrategy.{SwitchSignalStrategy, SwitchStreamStrategy} // @TODO[Scala3] Put this trait together with BaseObservable in the same file, and make BaseObservable sealed. @@ -20,8 +18,6 @@ object Observable { implicit val switchSignalStrategy: FlattenStrategy[Signal, Signal, Signal] = SwitchSignalStrategy - implicit val switchFutureStrategy: FlattenStrategy[Observable, Future, EventStream] = SwitchFutureStrategy - /** Provides debug* methods on Observable: debugSpy, debugLogEvents, debugBreakErrors, etc. */ implicit def toDebuggableObservable[A](observable: Observable[A]): DebuggableObservable[Observable, A] = new DebuggableObservable[Observable, A](observable) diff --git a/src/main/scala/com/raquo/airstream/core/Protected.scala b/src/main/scala/com/raquo/airstream/core/Protected.scala index 1c6ce882..80c125a7 100644 --- a/src/main/scala/com/raquo/airstream/core/Protected.scala +++ b/src/main/scala/com/raquo/airstream/core/Protected.scala @@ -33,4 +33,8 @@ object Protected { @inline def tryNow[A](signal: Signal[A])(implicit @unused ev: Protected): Try[A] = signal.tryNow() @inline def now[A](signal: Signal[A])(implicit @unused ev: Protected): A = signal.now() + + @inline def maybeWillStart[O[+_] <: Observable[_]](observable: BaseObservable[O, _]): Unit = { + BaseObservable.maybeWillStart(observable) + } } diff --git a/src/main/scala/com/raquo/airstream/core/Signal.scala b/src/main/scala/com/raquo/airstream/core/Signal.scala index b0cc37c3..014fba0e 100644 --- a/src/main/scala/com/raquo/airstream/core/Signal.scala +++ b/src/main/scala/com/raquo/airstream/core/Signal.scala @@ -8,7 +8,7 @@ import com.raquo.airstream.custom.{CustomSignalSource, CustomSource} import com.raquo.airstream.debug.{DebuggableSignal, Debugger, DebuggerSignal} import com.raquo.airstream.distinct.DistinctSignal import com.raquo.airstream.misc.generated._ -import com.raquo.airstream.misc.{FoldLeftSignal, MapEventStream, MapSignal} +import com.raquo.airstream.misc.{ChangesEventStream, FoldLeftSignal, MapSignal} import com.raquo.airstream.ownership.Owner import com.raquo.airstream.split.{SplittableOneSignal, SplittableSignal} import com.raquo.airstream.state.{ObservedSignal, OwnedSignal, Val} @@ -44,8 +44,9 @@ trait Signal[+A] extends Observable[A] with BaseObservable[Signal, A] with Signa /** @param operator Note: Must not throw! */ def composeChanges[AA >: A]( operator: EventStream[A] => EventStream[AA] + //emitChangeOnRestart: Boolean = false ): Signal[AA] = { - composeAll(operator, initialOperator = identity) + composeAll(changesOperator = operator, initialOperator = identity/*, emitChangeOnRestart*/) } /** @param changesOperator Note: Must not throw! @@ -54,11 +55,19 @@ trait Signal[+A] extends Observable[A] with BaseObservable[Signal, A] with Signa def composeAll[B]( changesOperator: EventStream[A] => EventStream[B], initialOperator: Try[A] => Try[B] + //emitChangeOnRestart: Boolean = false ): Signal[B] = { + //val changesStream = if (emitChangeOnRestart) changesEmitChangeOnRestart else changes changesOperator(changes).toSignalWithTry(initialOperator(tryNow())) } - def changes: EventStream[A] = new MapEventStream[A, A](parent = this, project = identity, recover = None) + def changes: EventStream[A] = { + new ChangesEventStream[A](parent = this/*, emitChangeOnRestart = false*/) + } + + //def changesEmitChangeOnRestart: EventStream[A] = { + // new ChangesEventStream[A](parent = this, emitChangeOnRestart = true) + //} /** * @param makeInitial Note: guarded against exceptions @@ -91,7 +100,7 @@ trait Signal[+A] extends Observable[A] with BaseObservable[Signal, A] with Signa * @param fn (prev, next) => isSame */ override def distinctTry(fn: (Try[A], Try[A]) => Boolean): Signal[A] = { - new DistinctSignal[A](parent = this, fn) + new DistinctSignal[A](parent = this, fn, resetOnStop = false) } /** @param pf Note: guarded against exceptions */ @@ -120,18 +129,30 @@ trait Signal[+A] extends Observable[A] with BaseObservable[Signal, A] with Signa override def toObservable: Signal[A] = this - /** Here we need to ensure that Signal's default value has been evaluated. - * It is important because if a Signal gets started by means of its .changes - * stream acquiring an observer, nothing else would trigger this evaluation - * because initialValue is not directly used by the .changes stream. - * However, when the events start coming in, we will need this initialValue - * because Signal needs to know when its current value has changed. + /** Here we need to ensure that Signal's default value has been evaluated at + * least once. It is important because if a Signal gets started by means of + * its .changes stream acquiring an observer, nothing else would trigger + * this evaluation because initialValue is not directly used by the .changes + * stream. However, when the events start coming in, we will need this + * initialValue because Signal needs to know when its current value has + * changed. */ override protected[this] def onStart(): Unit = { + //println(s"$this onStart") tryNow() // trigger setCurrentValue if we didn't initialize this before super.onStart() } + /** Recalculate the signal's current value. Typically this asks the parent signal + * for its current value, and does the math from there, according to the particular + * signal's logic (e.g. MapSignal would apply the `project` function. + * + * This method is used to calculate the signal's initial value, but also to + * recalculate, this signal's value, to re-sync it with the parent, when this signal + * is restarted after being stopped. See https://github.com/raquo/Airstream/issues/43 + */ + protected def currentValueFromParent(): Try[A] + // @TODO[API] Use pattern match instead when isInstanceOf performance is fixed: https://github.com/scala-js/scala-js/issues/3815 override protected def onAddedExternalObserver(observer: Observer[A]): Unit = { observer.onTry(tryNow()) // send current value immediately @@ -149,6 +170,17 @@ object Signal { new FutureSignal(future) } + /** Note: If the future is already resolved by the time this signal is started, + * the provided initial value is not used, and the future's value is used as + * the initial (and only) value instead. + */ + def fromFuture[A](future: Future[A], initial: => A): Signal[A] = { + new FutureSignal(future).map { + case None => initial + case Some(value) => value + } + } + def fromJsPromise[A](promise: js.Promise[A]): Signal[Option[A]] = { new FutureSignal(promise.toFuture) } diff --git a/src/main/scala/com/raquo/airstream/core/Transaction.scala b/src/main/scala/com/raquo/airstream/core/Transaction.scala index 613ef2d2..d03279cd 100644 --- a/src/main/scala/com/raquo/airstream/core/Transaction.scala +++ b/src/main/scala/com/raquo/airstream/core/Transaction.scala @@ -1,6 +1,6 @@ package com.raquo.airstream.core -import com.raquo.airstream.util.JsPriorityQueue +import com.raquo.airstream.util.{GlobalCounter, JsPriorityQueue} import scala.scalajs.js @@ -9,7 +9,7 @@ import scala.scalajs.js class Transaction(private[Transaction] var code: Transaction => Any) { // @TODO this is not used except for debug logging. Remove eventually - //val id: Int = Transaction.nextId() + val id: Int = Transaction.nextId() //println(s" - create trx $id") @@ -32,14 +32,7 @@ class Transaction(private[Transaction] var code: Transaction => Any) { } } -object Transaction { // extends GlobalCounter { - - //private var lastId: Int = 0; - - //private def nextId(): Int = { - // lastId += 1 - // lastId - //} +object Transaction extends GlobalCounter { // @nc[remove] private object pendingTransactions { @@ -66,6 +59,7 @@ object Transaction { // extends GlobalCounter { } def done(transaction: Transaction): Unit = { + //println(s"--done trx: ${transaction.id}") //if (lastId > 50) { // throw new Exception(">>> Overflow!!!!!") //} diff --git a/src/main/scala/com/raquo/airstream/core/WritableEventStream.scala b/src/main/scala/com/raquo/airstream/core/WritableEventStream.scala index c981c6e5..0e1ca093 100644 --- a/src/main/scala/com/raquo/airstream/core/WritableEventStream.scala +++ b/src/main/scala/com/raquo/airstream/core/WritableEventStream.scala @@ -7,6 +7,7 @@ import scala.util.Try trait WritableEventStream[A] extends EventStream[A] with WritableObservable[A] { override protected[this] def fireValue(nextValue: A, transaction: Transaction): Unit = { + //println(s"$this > FIRE > $nextValue") // Note: Removal of observers is always done at the end of a transaction, so the iteration here is safe // === CAUTION === @@ -26,6 +27,7 @@ trait WritableEventStream[A] extends EventStream[A] with WritableObservable[A] { } override protected[this] def fireError(nextError: Throwable, transaction: Transaction): Unit = { + //println(s"$this > FIRE > $nextError") // Note: Removal of observers is always done at the end of a transaction, so the iteration here is safe // === CAUTION === diff --git a/src/main/scala/com/raquo/airstream/core/WritableObservable.scala b/src/main/scala/com/raquo/airstream/core/WritableObservable.scala index a08ed417..780beee2 100644 --- a/src/main/scala/com/raquo/airstream/core/WritableObservable.scala +++ b/src/main/scala/com/raquo/airstream/core/WritableObservable.scala @@ -52,9 +52,14 @@ trait WritableObservable[A] extends Observable[A] { protected val internalObservers: ObserverList[InternalObserver[A]] = new ObserverList(js.Array()) override def addObserver(observer: Observer[A])(implicit owner: Owner): Subscription = { + //println(s"$this >> maybeWillStart") + maybeWillStart() + //println(s"$this << maybeWillStart") val subscription = addExternalObserver(observer, owner) onAddedExternalObserver(observer) + //println(s"$this >> ao") maybeStart() + //println(s"$this << ao") subscription } @@ -69,7 +74,12 @@ trait WritableObservable[A] extends Observable[A] { /** Child observable should call this method on its parents when it is started. * This observable calls [[onStart]] if this action has given it its first observer (internal or external). */ - override protected[airstream] def addInternalObserver(observer: InternalObserver[A]): Unit = { + override protected[airstream] def addInternalObserver(observer: InternalObserver[A], shouldCallMaybeWillStart: Boolean): Unit = { + //println(s"$this > aio shouldCallMaybeWillStart=$shouldCallMaybeWillStart") + if (!isStarted && shouldCallMaybeWillStart) { + maybeWillStart() + } + //println(s"$this < aio") internalObservers.push(observer) maybeStart() } diff --git a/src/main/scala/com/raquo/airstream/core/WritableSignal.scala b/src/main/scala/com/raquo/airstream/core/WritableSignal.scala index 3b2e6ed7..278d068a 100644 --- a/src/main/scala/com/raquo/airstream/core/WritableSignal.scala +++ b/src/main/scala/com/raquo/airstream/core/WritableSignal.scala @@ -5,14 +5,6 @@ import scala.util.{Failure, Success, Try} trait WritableSignal[A] extends Signal[A] with WritableObservable[A] { - /** Evaluate initial value of this [[Signal]]. - * This method must only be called once, when this value is first needed. - * You should override this method as `def` (no `val` or lazy val) to avoid - * holding a reference to the initial value beyond the duration of its relevance. - */ - // @TODO[Integrity] ^^^ Does this memory management advice even hold water? - protected def initialValue: Try[A] - protected var maybeLastSeenCurrentValue: js.UndefOr[Try[A]] = js.undefined protected def setCurrentValue(newValue: Try[A]): Unit = { @@ -22,9 +14,9 @@ trait WritableSignal[A] extends Signal[A] with WritableObservable[A] { /** Note: Initial value is only evaluated if/when needed (when there are observers) */ override protected[airstream] def tryNow(): Try[A] = { maybeLastSeenCurrentValue.getOrElse { - val currentValue = initialValue - setCurrentValue(currentValue) - currentValue + val nextValue = currentValueFromParent() + setCurrentValue(nextValue) + nextValue } } @@ -38,6 +30,7 @@ trait WritableSignal[A] extends Signal[A] with WritableObservable[A] { /** Signal propagates only if its value has changed */ override protected def fireTry(nextValue: Try[A], transaction: Transaction): Unit = { + //println(s"$this > FIRE > $nextValue") setCurrentValue(nextValue) diff --git a/src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala b/src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala index e4be32ce..df34d96b 100644 --- a/src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala +++ b/src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala @@ -1,41 +1,65 @@ package com.raquo.airstream.custom -import com.raquo.airstream.core.{ Signal, WritableSignal } +import com.raquo.airstream.core.{Signal, Transaction, WritableSignal} import com.raquo.airstream.custom.CustomSource._ -import scala.util.{ Success, Try } +import scala.util.{Success, Try} // @TODO[Test] needs testing /** Use this to easily create a custom signal from an external source * * See docs on custom sources, and [[CustomSource.Config]] + * + * @param cacheInitialValue if false, this signal's initial value will be re-evaluated on every + * restart (so long as it does not emit any values) */ class CustomSignalSource[A] ( getInitialValue: => Try[A], makeConfig: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => CustomSource.Config, + cacheInitialValue: Boolean = false ) extends WritableSignal[A] with CustomSource[A] { - override protected[this] def initialValue: Try[A] = getInitialValue + private var hasEmittedEvents = false + + override protected[this] val config: Config = makeConfig( + value => { + hasEmittedEvents = true + new Transaction(fireTry(value, _)) + }, + tryNow, + () => startIndex, + () => isStarted + ) - override protected[this] val config: Config = makeConfig(_fireTry, tryNow, getStartIndex, getIsStarted) + override protected def currentValueFromParent(): Try[A] = { + // #Note See also SignalFromEventStream for similar logic + // #Note This can be called from inside tryNow(), so make sure to avoid an infinite loop + if (maybeLastSeenCurrentValue.nonEmpty && (hasEmittedEvents || cacheInitialValue)) { + tryNow() + } else { + getInitialValue + } + } } object CustomSignalSource { def apply[A]( - initial: => A + initial: => A, + cacheInitialValue: Boolean = false )( config: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => Config ): Signal[A] = { - new CustomSignalSource[A](Success(initial), config) + new CustomSignalSource[A](Success(initial), config, cacheInitialValue) } def fromTry[A]( - initial: => Try[A] + initial: => Try[A], + cacheInitialValue: Boolean = false )( config: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => Config ): Signal[A] = { - new CustomSignalSource[A](initial, config) + new CustomSignalSource[A](initial, config, cacheInitialValue) } } diff --git a/src/main/scala/com/raquo/airstream/custom/CustomSource.scala b/src/main/scala/com/raquo/airstream/custom/CustomSource.scala index ba93a641..caa7deb1 100644 --- a/src/main/scala/com/raquo/airstream/custom/CustomSource.scala +++ b/src/main/scala/com/raquo/airstream/custom/CustomSource.scala @@ -1,6 +1,6 @@ package com.raquo.airstream.custom -import com.raquo.airstream.core.{ Transaction, WritableObservable } +import com.raquo.airstream.core.{Transaction, WritableObservable} import com.raquo.airstream.custom.CustomSource._ import scala.util.Try @@ -24,30 +24,14 @@ trait CustomSource[A] extends WritableObservable[A] { protected[this] var startIndex: StartIndex = 0 - - protected[this] val _fireValue: FireValue[A] = { value => - //println(s"> init trx from CustomSource(${value})") - new Transaction(fireValue(value, _)) - } - - protected[this] val _fireError: FireError = { error => - //println(s"> init error trx from CustomSource(${error})") - new Transaction(fireError(error, _)) - } - - protected[this] val _fireTry: SetCurrentValue[A] = { value => - //println(s"> init try trx from CustomSource(${value})") - new Transaction(fireTry(value, _)) + override protected def onWillStart(): Unit = { + startIndex += 1 + config.onWillStart() } - protected[this] val getStartIndex: GetStartIndex = () => startIndex - - protected[this] val getIsStarted: GetIsStarted = () => isStarted - override protected[this] def onStart(): Unit = { - startIndex += 1 - Try(config.onStart()).recover { - case err: Throwable => _fireError(err) + Try(config.onStart()).recover[Unit] { + case err: Throwable => new Transaction(fireError(err, _)) } super.onStart() } @@ -62,9 +46,10 @@ object CustomSource { /** See docs for custom sources */ final class Config private ( + val onWillStart: () => Unit, val onStart: () => Unit, val onStop: () => Unit - ) { + ) { self => /** Create a version of a config that only runs start / stop if the predicate passes. * - `start` will be run when the CustomSource is about to start @@ -73,19 +58,24 @@ object CustomSource { * if your `start` code ran the last time CustomSource started */ def when(passes: () => Boolean): Config = { - var started = false + var passed = false new Config( - () => { - if (passes()) { - started = true - onStart() + onWillStart = () => { + passed = passes() + if (passed) { + self.onWillStart() + } + }, + onStart = () => { + if (passed) { + self.onStart() } }, onStop = () => { - if (started) { - onStop() + if (passed) { + self.onStop() } - started = false + passed = false } ) } @@ -93,8 +83,19 @@ object CustomSource { object Config { - def apply(onStart: () => Unit, onStop: () => Unit): Config = { - new Config(onStart, onStop) + def apply( + onWillStart: () => Unit, + onStart: () => Unit, + onStop: () => Unit + ): Config = { + new Config(onWillStart, onStart, onStop) + } + + def apply( + onStart: () => Unit, + onStop: () => Unit + ): Config = { + new Config(onWillStart = () => (), onStart, onStop) } } diff --git a/src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala b/src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala index f41bbc0d..d53fea4c 100644 --- a/src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala +++ b/src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala @@ -1,6 +1,6 @@ package com.raquo.airstream.custom -import com.raquo.airstream.core.{ EventStream, WritableEventStream } +import com.raquo.airstream.core.{EventStream, Transaction, WritableEventStream} import com.raquo.airstream.custom.CustomSource._ /** Use this to easily create a custom signal from an external source @@ -11,7 +11,16 @@ class CustomStreamSource[A] private ( makeConfig: (FireValue[A], FireError, GetStartIndex, GetIsStarted) => CustomSource.Config, ) extends WritableEventStream[A] with CustomSource[A] { - override protected[this] val config: Config = makeConfig(_fireValue, _fireError, getStartIndex, getIsStarted) + override protected[this] val config: Config = makeConfig( + value => { + new Transaction(fireValue(value, _)) + }, + err => { + new Transaction(fireError(err, _)) + }, + () => startIndex, + () => isStarted + ) } object CustomStreamSource { diff --git a/src/main/scala/com/raquo/airstream/debug/DebuggableSignal.scala b/src/main/scala/com/raquo/airstream/debug/DebuggableSignal.scala index 496c96c7..33f0b3af 100644 --- a/src/main/scala/com/raquo/airstream/debug/DebuggableSignal.scala +++ b/src/main/scala/com/raquo/airstream/debug/DebuggableSignal.scala @@ -20,30 +20,33 @@ import scala.util.{Failure, Success, Try} */ class DebuggableSignal[+A](override val observable: Signal[A]) extends DebuggableObservable[Signal, A](observable) { - /** Execute fn when signal is evaluating its initial value */ - def debugSpyInitialEval(fn: Try[A] => Unit): Signal[A] = { - val debugger = Debugger(onInitialEval = fn) + /** Execute fn when signal is evaluating its `currentValueFromParent`. + * This is typically triggered when evaluating signal's initial value onStart, + * as well as on subsequent re-starts when the signal is syncing its value + * to the parent's new current value. */ + def debugSpyEvalFromParent(fn: Try[A] => Unit): Signal[A] = { + val debugger = Debugger(onEvalFromParent = fn) observable.debugWith(debugger) } /** Log when signal is evaluating its initial value (if `when` passes at that time) */ - def debugLogInitialEval( + def debugLogEvalFromParent( when: Try[A] => Boolean = always, useJsLogger: Boolean = false ): Signal[A] = { - debugSpyInitialEval { value => + debugSpyEvalFromParent { value => if (when(value)) { value match { - case Success(ev) => log("initial-eval[event]", Some(ev), useJsLogger) - case Failure(err) => log("initial-eval[error]", Some(err), useJsLogger) + case Success(ev) => log("eval-from-parent", Some(ev), useJsLogger) + case Failure(err) => log("eval-from-parent[error]", Some(err), useJsLogger) } } } } /** Trigger JS debugger when signal is evaluating its initial value (if `when` passes at that time) */ - def debugBreakInitialEval(when: Try[A] => Boolean = always): Signal[A] = { - debugSpyInitialEval { value => + def debugBreakEvalFromParent(when: Try[A] => Boolean = always): Signal[A] = { + debugSpyEvalFromParent { value => if (when(value)) { js.special.debugger() } diff --git a/src/main/scala/com/raquo/airstream/debug/Debugger.scala b/src/main/scala/com/raquo/airstream/debug/Debugger.scala index a023ca98..6641a3a3 100644 --- a/src/main/scala/com/raquo/airstream/debug/Debugger.scala +++ b/src/main/scala/com/raquo/airstream/debug/Debugger.scala @@ -4,11 +4,14 @@ import scala.util.Try /** Debugger for observables * - * @param onInitialEval fired when initial value is evaluated. Only for signals. + * @param onEvalFromParent Only for signals. Fired when signal calls `currentValueFromParent`, which happens + * 1) when the signal is first started and its initial value is evaluated, AND + * 2) also when the signal is re-started after being stopped, when that method is called + * to re-sync this signal's value with the parent. */ case class Debugger[-A] ( onStart: () => Unit = () => (), onStop: () => Unit = () => (), onFire: Try[A] => Unit = (_: Try[A]) => (), - onInitialEval: Try[A] => Unit = (_: Try[A]) => () + onEvalFromParent: Try[A] => Unit = (_: Try[A]) => () ) diff --git a/src/main/scala/com/raquo/airstream/debug/DebuggerEventStream.scala b/src/main/scala/com/raquo/airstream/debug/DebuggerEventStream.scala index 945b9d8d..bfbde308 100644 --- a/src/main/scala/com/raquo/airstream/debug/DebuggerEventStream.scala +++ b/src/main/scala/com/raquo/airstream/debug/DebuggerEventStream.scala @@ -1,17 +1,20 @@ package com.raquo.airstream.debug -import com.raquo.airstream.core.{EventStream, Protected, Transaction, WritableEventStream} +import com.raquo.airstream.common.SingleParentEventStream +import com.raquo.airstream.core.{EventStream, Protected, Transaction} import scala.util.{Failure, Success, Try} /** See [[DebuggableObservable]] and [[DebuggableSignal]] for user-facing debug methods */ class DebuggerEventStream[A]( - override protected val parent: EventStream[A], + override protected[this] val parent: EventStream[A], override protected val debugger: Debugger[A] -) extends WritableEventStream[A] with DebuggerObservable[A] { +) extends SingleParentEventStream[A, A] with DebuggerObservable[A] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 + override protected def defaultDisplayName: String = DebuggerObservable.defaultDisplayName(parent) + override protected[this] def fireValue(nextValue: A, transaction: Transaction): Unit = { debugFireTry(Success(nextValue)) super.fireValue(nextValue, transaction) diff --git a/src/main/scala/com/raquo/airstream/debug/DebuggerObservable.scala b/src/main/scala/com/raquo/airstream/debug/DebuggerObservable.scala index 7d820817..bc4c082d 100644 --- a/src/main/scala/com/raquo/airstream/debug/DebuggerObservable.scala +++ b/src/main/scala/com/raquo/airstream/debug/DebuggerObservable.scala @@ -1,28 +1,16 @@ package com.raquo.airstream.debug -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.AirstreamError +import com.raquo.airstream.common.InternalTryObserver import com.raquo.airstream.core.AirstreamError.DebugError +import com.raquo.airstream.core.{AirstreamError, Observable} import scala.util.Try /** See [[DebuggableObservable]] and [[DebuggableSignal]] for user-facing debug methods */ -trait DebuggerObservable[A] extends SingleParentObservable[A, A] with InternalTryObserver[A] { +trait DebuggerObservable[A] extends InternalTryObserver[A] { protected val debugger: Debugger[A] - override def defaultDisplayName: String = { - parent match { - case _: DebuggerObservable[_] => - // When chaining multiple debug observables, they will inherit the parent's displayName - parent.displayName - case _ => - // We need to indicate that this isn't the original observable, but a debugged one, - // otherwise debugging could get really confusing - s"${parent.displayName}|Debug" - } - } - protected[this] def debugFireTry(nextValue: Try[A]): Unit = { try { debugger.onFire(nextValue) @@ -49,3 +37,19 @@ trait DebuggerObservable[A] extends SingleParentObservable[A, A] with InternalTr } } } + +object DebuggerObservable { + + def defaultDisplayName[A](parent: Observable[A]): String = { + parent match { + case _: DebuggerObservable[_] => + // #TODO[UX] This could be confusing. But the alternative (|Debug|Debug|Debug names) is annoying. + // When chaining multiple debug observables, they will inherit the parent's displayName + parent.displayName + case _ => + // We need to indicate that this isn't the original observable, but a debugged one, + // otherwise debugging could get really confusing + s"${parent.displayName}|Debug" + } + } +} diff --git a/src/main/scala/com/raquo/airstream/debug/DebuggerSignal.scala b/src/main/scala/com/raquo/airstream/debug/DebuggerSignal.scala index 3b17680b..80804126 100644 --- a/src/main/scala/com/raquo/airstream/debug/DebuggerSignal.scala +++ b/src/main/scala/com/raquo/airstream/debug/DebuggerSignal.scala @@ -1,28 +1,31 @@ package com.raquo.airstream.debug +import com.raquo.airstream.common.SingleParentSignal import com.raquo.airstream.core.AirstreamError.DebugError -import com.raquo.airstream.core.{AirstreamError, Protected, Signal, Transaction, WritableSignal} +import com.raquo.airstream.core.{AirstreamError, Protected, Signal, Transaction} import scala.util.Try /** See [[DebuggableObservable]] and [[DebuggableSignal]] for user-facing debug methods */ class DebuggerSignal[A]( - override protected val parent: Signal[A], + override protected[this] val parent: Signal[A], override protected val debugger: Debugger[A] -) extends WritableSignal[A] with DebuggerObservable[A] { +) extends SingleParentSignal[A, A] with DebuggerObservable[A] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 - override protected[this] def initialValue: Try[A] = { - val initial = parent.tryNow() + override protected def defaultDisplayName: String = DebuggerObservable.defaultDisplayName(parent) + + override protected def currentValueFromParent(): Try[A] = { + val parentValue = parent.tryNow() try { - debugger.onInitialEval(initial) + debugger.onEvalFromParent(parentValue) } catch { case err: Throwable => - val maybeCause = initial.toEither.left.toOption + val maybeCause = parentValue.toEither.left.toOption AirstreamError.sendUnhandledError(DebugError(err, cause = maybeCause)) } - initial + parentValue } override protected[this] def fireTry(nextValue: Try[A], transaction: Transaction): Unit = { diff --git a/src/main/scala/com/raquo/airstream/distinct/DistinctEventStream.scala b/src/main/scala/com/raquo/airstream/distinct/DistinctEventStream.scala index 55004c7b..9adfab43 100644 --- a/src/main/scala/com/raquo/airstream/distinct/DistinctEventStream.scala +++ b/src/main/scala/com/raquo/airstream/distinct/DistinctEventStream.scala @@ -1,26 +1,34 @@ package com.raquo.airstream.distinct -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{EventStream, Protected, Transaction, WritableEventStream} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, Protected, Transaction} import scala.scalajs.js import scala.util.Try /** Emits only values that are distinct from the last emitted value, according to isSame function */ class DistinctEventStream[A]( - override protected val parent: EventStream[A], - isSame: (Try[A], Try[A]) => Boolean -) extends WritableEventStream[A] with SingleParentObservable[A, A] with InternalTryObserver[A] { + override protected[this] val parent: EventStream[A], + isSame: (Try[A], Try[A]) => Boolean, + resetOnStop: Boolean +) extends SingleParentEventStream[A, A] with InternalTryObserver[A] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 private var maybeLastSeenValue: js.UndefOr[Try[A]] = js.undefined override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { - val distinct = maybeLastSeenValue.map(!isSame(_, nextValue)).getOrElse(true) + val isDistinct = maybeLastSeenValue.map(!isSame(_, nextValue)).getOrElse(true) maybeLastSeenValue = nextValue - if (distinct) { + if (isDistinct) { fireTry(nextValue, transaction) } } + + override protected[this] def onStop(): Unit = { + if (resetOnStop) { + maybeLastSeenValue = js.undefined + } + super.onStop() + } } diff --git a/src/main/scala/com/raquo/airstream/distinct/DistinctSignal.scala b/src/main/scala/com/raquo/airstream/distinct/DistinctSignal.scala index 9e220365..7f0f1222 100644 --- a/src/main/scala/com/raquo/airstream/distinct/DistinctSignal.scala +++ b/src/main/scala/com/raquo/airstream/distinct/DistinctSignal.scala @@ -1,15 +1,16 @@ package com.raquo.airstream.distinct -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{Protected, Signal, Transaction, WritableSignal} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} +import com.raquo.airstream.core.{Protected, Signal, Transaction} import scala.util.Try /** Emits only values that are distinct from the last emitted value, according to isSame function */ class DistinctSignal[A]( - override protected val parent: Signal[A], - isSame: (Try[A], Try[A]) => Boolean -) extends WritableSignal[A] with SingleParentObservable[A, A] with InternalTryObserver[A] { + override protected[this] val parent: Signal[A], + isSame: (Try[A], Try[A]) => Boolean, + resetOnStop: Boolean +) extends SingleParentSignal[A, A] with InternalTryObserver[A] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 @@ -19,5 +20,19 @@ class DistinctSignal[A]( } } - override protected def initialValue: Try[A] = parent.tryNow() + override protected def currentValueFromParent(): Try[A] = parent.tryNow() + + /** Special implementation to add the distinct-ness filter */ + override protected def updateCurrentValueFromParent(): Try[A] = { + val currentValue = tryNow() + val nextValue = currentValueFromParent() + // #Note We check this signal's standard distinction condition with !isSame instead of `==` + // because isSame might be something incompatible, e.g. reference equality + if (resetOnStop || !isSame(nextValue, currentValue)) { + setCurrentValue(nextValue) + nextValue + } else { + currentValue + } + } } diff --git a/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala b/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala index fdec1a69..d14159b3 100644 --- a/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala +++ b/src/main/scala/com/raquo/airstream/eventbus/EventBusStream.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.eventbus import com.raquo.airstream.common.InternalNextErrorObserver -import com.raquo.airstream.core.{ EventStream, Transaction, WritableEventStream } +import com.raquo.airstream.core.{EventStream, Protected, Transaction, WritableEventStream} import scala.scalajs.js @@ -17,7 +17,7 @@ class EventBusStream[A] private[eventbus] () extends WritableEventStream[A] with @inline private[eventbus] def addSource(sourceStream: EventStream[A]): Unit = { sourceStreams.push(sourceStream) if (isStarted) { - sourceStream.addInternalObserver(this) + sourceStream.addInternalObserver(this, shouldCallMaybeWillStart = true) } } @@ -59,8 +59,12 @@ class EventBusStream[A] private[eventbus] () extends WritableEventStream[A] with new Transaction(fireError(nextError, _)) } + override protected def onWillStart(): Unit = { + sourceStreams.foreach(Protected.maybeWillStart) + } + override protected[this] def onStart(): Unit = { - sourceStreams.foreach(_.addInternalObserver(this)) + sourceStreams.foreach(_.addInternalObserver(this, shouldCallMaybeWillStart = false)) super.onStart() } diff --git a/src/main/scala/com/raquo/airstream/flatten/ConcurrentEventStream.scala b/src/main/scala/com/raquo/airstream/flatten/ConcurrentEventStream.scala index 8c363c19..c0da0c69 100644 --- a/src/main/scala/com/raquo/airstream/flatten/ConcurrentEventStream.scala +++ b/src/main/scala/com/raquo/airstream/flatten/ConcurrentEventStream.scala @@ -1,22 +1,21 @@ package com.raquo.airstream.flatten -import com.raquo.airstream.common.{ InternalNextErrorObserver, SingleParentObservable } -import com.raquo.airstream.core.{ EventStream, InternalObserver, Observable, Signal, Transaction, WritableEventStream } +import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, InternalObserver, Observable, Protected, Signal, Transaction} import scala.scalajs.js -import scala.util.{ Failure, Success } +import scala.util.{Failure, Success} /** This is essentially a dynamic version of `EventStream.merge`. * - The resulting stream re-emits all the events emitted by all of the streams * previously emitted by the input observable. - * - If you stop observing the resulting stream, it will forget all of the + * - If you restart the resulting stream, it will remember and resubscribe to all of the * streams it previously listened to. - * - When you start it up again, it will start listening to the input observable - * from scratch, as if it's the first time you started it. - * */ + * - If the input observable emits the same stream more than once, that stream will only added once. + */ class ConcurrentEventStream[A]( override protected[this] val parent: Observable[EventStream[A]] -) extends WritableEventStream[A] with SingleParentObservable[EventStream[A], A] with InternalNextErrorObserver[EventStream[A]] { +) extends SingleParentEventStream[EventStream[A], A] with InternalNextErrorObserver[EventStream[A]] { private val accumulatedStreams: js.Array[EventStream[A]] = js.Array() @@ -27,38 +26,57 @@ class ConcurrentEventStream[A]( override protected val topoRank: Int = 1 + override protected def onWillStart(): Unit = { + super.onWillStart() + accumulatedStreams.foreach(Protected.maybeWillStart) + parent match { + case signal: Signal[EventStream[A @unchecked] @unchecked] => + signal.tryNow() match { + case Success(stream) => + // We add internal observer later, in `onStart`. onWillStart should not start any observables. + maybeAddStream(stream, addInternalObserver = false) + case _ => () + } + case _ => () + } + } + override protected[this] def onStart(): Unit = { + super.onStart() + accumulatedStreams.foreach(_.addInternalObserver(internalEventObserver, shouldCallMaybeWillStart = false)) parent match { - case signal: Signal[EventStream[A] @unchecked] => + case signal: Signal[EventStream[A @unchecked] @unchecked] => signal.tryNow() match { - case Success(initialStream) => - addStream(initialStream) case Failure(err) => - // @TODO[API] Not 100% that we should emit this error, but since + // @TODO[API] Not 100% sure that we should emit this error, but since // we expect to use signal's current value, I think this is right. - new Transaction(fireError(err, _)) + new Transaction(fireError(err, _)) // #Note[onStart,trx,loop] + case _ => () } - case _ => js.undefined + case _ => () } - super.onStart() } override protected[this] def onStop(): Unit = { accumulatedStreams.foreach(Transaction.removeInternalObserver(_, internalEventObserver)) - accumulatedStreams.clear() super.onStop() } override protected def onNext(nextStream: EventStream[A], transaction: Transaction): Unit = { - addStream(nextStream) + maybeAddStream(nextStream, addInternalObserver = true) } override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { fireError(nextError, transaction) } - private def addStream(stream: EventStream[A]): Unit = { - accumulatedStreams.push(stream) - stream.addInternalObserver(internalEventObserver) + private def maybeAddStream(stream: EventStream[A], addInternalObserver: Boolean): Unit = { + if (!accumulatedStreams.contains(stream)) { + accumulatedStreams.push(stream) + if (addInternalObserver) { + stream.addInternalObserver(internalEventObserver, shouldCallMaybeWillStart = true) + } + } } + } diff --git a/src/main/scala/com/raquo/airstream/flatten/ConcurrentFutureStream.scala b/src/main/scala/com/raquo/airstream/flatten/ConcurrentFutureStream.scala index 26d006ab..e69de29b 100644 --- a/src/main/scala/com/raquo/airstream/flatten/ConcurrentFutureStream.scala +++ b/src/main/scala/com/raquo/airstream/flatten/ConcurrentFutureStream.scala @@ -1,57 +0,0 @@ -package com.raquo.airstream.flatten - -import com.raquo.airstream.common.{ InternalNextErrorObserver, SingleParentObservable } -import com.raquo.airstream.core.{ Observable, Transaction, WritableEventStream } - -import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue // #TODO #nc remove this in 15.0.0 -import scala.concurrent.Future - -/** This stream emits the values that the parent observables' emitted futures resolve with, - * in the order in which they resolve (which is likely different from the order in which the futures are emitted). - * - * @param dropPreviousValues if enabled, this option makes this stream NOT emit values of futures that were emitted - * earlier than a future that has already resolved. So if the parent stream emits - * three futures and the third one resolves before the first two, this stream will NOT emit - * the values of the first two futures when they resolve. - * This option is useful for applications such as autocomplete results - */ -class ConcurrentFutureStream[A]( - protected[this] val parent: Observable[Future[A]], - dropPreviousValues: Boolean, - emitIfFutureCompleted: Boolean -) extends WritableEventStream[A] with SingleParentObservable[Future[A], A] with InternalNextErrorObserver[Future[A]] { - - // @TODO[Integrity] We should probably eventually deal with the vars' overflow issue - - private[this] var lastFutureIndex: Int = 0 - - private[this] var lastEmittedValueIndex: Int = 0 - - override protected val topoRank: Int = 1 - - override protected def onNext(nextFuture: Future[A], transaction: Transaction): Unit = { - lastFutureIndex += 1 - val nextFutureIndex = lastFutureIndex - if (!nextFuture.isCompleted || emitIfFutureCompleted) { - nextFuture.onComplete { nextValue => - if (!dropPreviousValues || (nextFutureIndex > lastEmittedValueIndex)) { - lastEmittedValueIndex = nextFutureIndex - // @TODO[API] Should lastEmittedValueIndex be updated only on success or also on failure? - //println(s"> init trx from ConcurrentFutureStream.onNext") - new Transaction(fireTry(nextValue, _)) - } - } - } - } - - override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { - lastFutureIndex += 1 - val nextFutureIndex = lastFutureIndex - if (!dropPreviousValues || (nextFutureIndex > lastEmittedValueIndex)) { - lastEmittedValueIndex = nextFutureIndex - // @TODO[API] Should lastEmittedValueIndex be updated only on success or also on failure? - // @TODO[Performance] We use future.onComplete to better match the timing of onNext. Perhaps this is a bit overkill. - Future.failed(nextError).onComplete(_ => new Transaction(fireError(nextError, _))) - } - } -} diff --git a/src/main/scala/com/raquo/airstream/flatten/FlattenStrategy.scala b/src/main/scala/com/raquo/airstream/flatten/FlattenStrategy.scala index 0cde2dcd..dc09511f 100644 --- a/src/main/scala/com/raquo/airstream/flatten/FlattenStrategy.scala +++ b/src/main/scala/com/raquo/airstream/flatten/FlattenStrategy.scala @@ -2,8 +2,6 @@ package com.raquo.airstream.flatten import com.raquo.airstream.core.{EventStream, Observable, Signal} -import scala.concurrent.Future - /** [[Observable.MetaObservable.flatten]] needs an instance of this trait to know how exactly to do the flattening. */ trait FlattenStrategy[-Outer[+_] <: Observable[_], -Inner[_], Output[+_] <: Observable[_]] { /** Must not throw */ @@ -26,16 +24,6 @@ object FlattenStrategy { } } - /** See docs for [[SwitchEventStream]] */ - object SwitchFutureStrategy extends FlattenStrategy[Observable, Future, EventStream] { - override def flatten[A](parent: Observable[Future[A]]): EventStream[A] = { - new SwitchEventStream[Future[A], A]( - parent = parent, - makeStream = EventStream.fromFuture(_, emitFutureIfCompleted = true) - ) - } - } - /** See docs for [[SwitchSignal]] */ object SwitchSignalStrategy extends FlattenStrategy[Signal, Signal, Signal] { override def flatten[A](parent: Signal[Signal[A]]): Signal[A] = { @@ -43,18 +31,4 @@ object FlattenStrategy { } } - /** See docs for [[ConcurrentFutureStream]] */ - object ConcurrentFutureStrategy extends FlattenStrategy[Observable, Future, EventStream] { - override def flatten[A](parent: Observable[Future[A]]): EventStream[A] = { - new ConcurrentFutureStream[A](parent, dropPreviousValues = false, emitIfFutureCompleted = true) - } - } - - // @TODO[Naming] this strategy needs a better name - /** See docs for [[ConcurrentFutureStream]] */ - object OverwriteFutureStrategy extends FlattenStrategy[Observable, Future, EventStream] { - override def flatten[A](parent: Observable[Future[A]]): EventStream[A] = { - new ConcurrentFutureStream[A](parent, dropPreviousValues = true, emitIfFutureCompleted = true) - } - } } diff --git a/src/main/scala/com/raquo/airstream/flatten/SwitchEventStream.scala b/src/main/scala/com/raquo/airstream/flatten/SwitchEventStream.scala index d6a879c1..40e7b8d6 100644 --- a/src/main/scala/com/raquo/airstream/flatten/SwitchEventStream.scala +++ b/src/main/scala/com/raquo/airstream/flatten/SwitchEventStream.scala @@ -1,10 +1,10 @@ package com.raquo.airstream.flatten -import com.raquo.airstream.common.{ InternalNextErrorObserver, SingleParentObservable } -import com.raquo.airstream.core.{ EventStream, InternalObserver, Observable, Signal, Transaction, WritableEventStream } +import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, InternalObserver, Observable, Protected, Signal, Transaction} import scala.scalajs.js -import scala.util.{ Failure, Success, Try } +import scala.util.{Failure, Success, Try} /** `parent` observable emits values that we convert into streams using `makeStream`. * @@ -24,21 +24,21 @@ import scala.util.{ Failure, Success, Try } * Warning: Similar to [[com.raquo.airstream.eventbus.EventBus]], this stream emits events in * a new transaction because its proper topoRank would need to be dynamic, which we don't support. * - * Note: this stream loses its memory if stopped. - * * @param makeStream Note: Must not throw */ class SwitchEventStream[I, O]( override protected[this] val parent: Observable[I], makeStream: I => EventStream[O] -) extends WritableEventStream[O] with SingleParentObservable[I, O] with InternalNextErrorObserver[I] { +) extends SingleParentEventStream[I, O] with InternalNextErrorObserver[I] { override protected val topoRank: Int = 1 - private[this] var firstStart: Boolean = true + private[this] val parentIsSignal: Boolean = parent.isInstanceOf[Signal[_]] private[this] var maybeCurrentEventStream: js.UndefOr[Try[EventStream[O]]] = js.undefined + private[this] var maybeNextEventStream: js.UndefOr[Try[EventStream[O]]] = js.undefined + // @TODO[Elegance] Maybe we should abstract away this kind of internal observer private[this] val internalEventObserver: InternalObserver[O] = InternalObserver[O]( onNext = (nextEvent, _) => { @@ -51,37 +51,36 @@ class SwitchEventStream[I, O]( ) override protected def onNext(nextValue: I, transaction: Transaction): Unit = { - val nextStream = makeStream(nextValue) - val isSameStream = maybeCurrentEventStream.exists { currentStream => - currentStream.isSuccess && (currentStream.get eq nextStream) - } - if (!isSameStream) { - removeInternalObserverFromCurrentEventStream() - maybeCurrentEventStream = Success(nextStream) - // If we're receiving events, this stream is started, so no need to check for that - nextStream.addInternalObserver(internalEventObserver) - } + switchToNextStream(nextStream = makeStream(nextValue), shouldCallMaybeWillStart = true) } override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { - removeInternalObserverFromCurrentEventStream() - maybeCurrentEventStream = Failure(nextError) - fireError(nextError, transaction) + switchToNextError(nextError, Some(transaction)) + } + + override protected def onWillStart(): Unit = { + super.onWillStart() // start parent + + if (parentIsSignal) { + val parentSignal = parent.asInstanceOf[Signal[I @unchecked]] + val newStream = parentSignal.tryNow().map(makeStream) + newStream.foreach(Protected.maybeWillStart) + maybeNextEventStream = newStream + } else { + maybeCurrentEventStream.foreach(_.foreach(Protected.maybeWillStart)) + } } override protected[this] def onStart(): Unit = { super.onStart() - if (firstStart) { - firstStart = false - maybeCurrentEventStream = parent match { - case signal: Signal[I @unchecked] => signal.tryNow().map(makeStream) - case _ => js.undefined - } - } - maybeCurrentEventStream.foreach { streamTry => - val initialStream = streamTry.fold(err => EventStream.fromTry(Failure(err), emitOnce = true), identity) - initialStream.addInternalObserver(internalEventObserver) + + maybeNextEventStream.foreach { + case Success(nextStream) => + switchToNextStream(nextStream, shouldCallMaybeWillStart = false) + case Failure(nextError) => + switchToNextError(nextError, transaction = None) } + maybeNextEventStream = js.undefined } override protected[this] def onStop(): Unit = { @@ -90,6 +89,24 @@ class SwitchEventStream[I, O]( super.onStop() } + private def switchToNextStream(nextStream: EventStream[O], shouldCallMaybeWillStart: Boolean): Unit = { + val isSameStream = maybeCurrentEventStream.exists { currentStream => + currentStream.isSuccess && (currentStream.get == nextStream) + } + if (!isSameStream) { + removeInternalObserverFromCurrentEventStream() + maybeCurrentEventStream = Success(nextStream) + // If we're receiving events, this stream is started, so no need to check for that + nextStream.addInternalObserver(internalEventObserver, shouldCallMaybeWillStart = shouldCallMaybeWillStart) + } + } + + private def switchToNextError(nextError: Throwable, transaction: Option[Transaction]): Unit = { + removeInternalObserverFromCurrentEventStream() + maybeCurrentEventStream = Failure(nextError) + transaction.fold[Unit](new Transaction(fireError(nextError, _)))(fireError(nextError, _)) // #Note[onStart,trx,loop] + } + private def removeInternalObserverFromCurrentEventStream(): Unit = { maybeCurrentEventStream.foreach { _.foreach { currentStream => Transaction.removeInternalObserver(currentStream, internalEventObserver) diff --git a/src/main/scala/com/raquo/airstream/flatten/SwitchSignal.scala b/src/main/scala/com/raquo/airstream/flatten/SwitchSignal.scala index bed203b3..2767dd73 100644 --- a/src/main/scala/com/raquo/airstream/flatten/SwitchSignal.scala +++ b/src/main/scala/com/raquo/airstream/flatten/SwitchSignal.scala @@ -1,10 +1,10 @@ package com.raquo.airstream.flatten -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{InternalObserver, Signal, Transaction, WritableSignal} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} +import com.raquo.airstream.core.{InternalObserver, Protected, Signal, Transaction} import scala.scalajs.js -import scala.util.Try +import scala.util.{Success, Try} /** This flattens a Signal[ Signal[A] ] into a Signal[A] * @@ -17,12 +17,10 @@ import scala.util.Try */ class SwitchSignal[A]( override protected[this] val parent: Signal[Signal[A]] -) extends WritableSignal[A] with SingleParentObservable[Signal[A], A] with InternalTryObserver[Signal[A]] { +) extends SingleParentSignal[Signal[A], A] with InternalTryObserver[Signal[A]] { override protected val topoRank: Int = 1 - override protected def initialValue: Try[A] = parent.tryNow().flatMap(_.tryNow()) - private[this] var maybeCurrentSignalTry: js.UndefOr[Try[Signal[A]]] = js.undefined private[this] def currentSignalTry: Try[Signal[A]] = maybeCurrentSignalTry.getOrElse { @@ -38,49 +36,100 @@ class SwitchSignal[A]( } ) + // #Note this is technically correct, I think, but in practice this method is not used in this Signal. + override protected def currentValueFromParent(): Try[A] = { + parent.tryNow().flatMap(_.tryNow()) + } + + override protected def updateCurrentValueFromParent(): Try[A] = { + // #Note this is overriding the parent implementation + switchToSignal(nextSignalTry = parent.tryNow(), isStarting = true) + tryNow() + } + override protected def onTry(nextSignalTry: Try[Signal[A]], transaction: Transaction): Unit = { - val isSameSignal = nextSignalTry.isSuccess && nextSignalTry == currentSignalTry + switchToSignal(nextSignalTry, isStarting = false) + } + + private def switchToSignal( + nextSignalTry: Try[Signal[A]], + isStarting: Boolean + ): Unit = { + val isSameSignal = (nextSignalTry, currentSignalTry) match { + case (Success(nextSignal), Success(currentSignal)) => nextSignal == currentSignal + case _ => false + } + + // If this SwitchSignal is starting, we just want to update its new current value, + // we don't want to emit anything in a new transaction (if we did that instead, any + // signals downstream of this signal would restart with a stale current value, and + // then emit the updated value in that new transaction – that would be annoying, + // I think) if (isSameSignal) { - // We want to re-emit the signal's current value in this case to be consistent - // with signals not having a built-in `==` check (since 0.15.0) + if (isStarting) { + setCurrentValue(nextSignalTry.flatMap(_.tryNow())) - //println(s"> init trx from SwitchSignal.onTry (same signal)") - new Transaction(fireTry(nextSignalTry.flatMap(_.tryNow()), _)) + } else { + // We want to re-emit the signal's current value even if switching to the same signal to be consistent + // with signals not having a built-in `==` check (since 0.15.0) + //println(s"> init trx from SwitchSignal.onTry (same signal)") + new Transaction(fireTry(nextSignalTry.flatMap(_.tryNow()), _)) // #Note[onStart,trx,loop] + } } else { removeInternalObserverFromCurrentSignal() maybeCurrentSignalTry = nextSignalTry - //println(s"> init trx from SwitchSignal.onTry (new signal)") - // Update this signal's value with nextSignal's current value (or an error if we don't have nextSignal) - new Transaction(trx => { - - // #Note: Timing is important here. - // 1. Create the `trx` transaction, since we need that boundary when flattening - // 2. Ensure next signal is started by adding an internal observer to it - // 3. Starting the signal might cause it to emit event(s) in a new transaction. - // For example, EventStream.fromSeq(1, 2, 3).startWith(0) will schedule three events in three transactions - // 4. Now that the next signal's current value is initialized, we can update SwitchSignal's current value to match - // IMPORTANT: This will be done IMMEDIATELY, without any delay because we're already inside `trx`. - // Conversely, any events scheduled in point (3) above will run AFTER `trx` is done. - // This is as desired. It lets SwitchSignal emit the next signal's initial value before - // emitting subsequent updates to it that might have been triggered by starting it. - // Prior to Airstream 0.15.0 the next signal's initial value would have been missed in such cases. - - // If we're receiving events, this signal is started, so no need to check for that - nextSignalTry.foreach { nextSignal => - nextSignal.addInternalObserver(internalEventObserver) - } - - fireTry(nextSignalTry.flatMap(_.tryNow()), trx) - }) + if (isStarting) { + + nextSignalTry.foreach(Protected.maybeWillStart) + + setCurrentValue(nextSignalTry.flatMap(_.tryNow())) + + nextSignalTry.foreach(_.addInternalObserver(internalEventObserver, shouldCallMaybeWillStart = false)) + + } else { + + //println(s"> init trx from SwitchSignal.onTry (new signal)") + // Update this signal's value with nextSignal's current value (or an error if we don't have nextSignal) + new Transaction(trx => { + + // #Note: Timing is important here. + // 1. Create the `trx` transaction, since we need that boundary when flattening + // 2. Ensure next signal is started by adding an internal observer to it + // 3. Starting the signal might cause it to emit event(s) in a new transaction. + // For example, EventStream.fromSeq(1, 2, 3).startWith(0) will schedule three events in three transactions + // 4. Now that the next signal's current value is initialized, we can update SwitchSignal's current value to match + // IMPORTANT: This will be done IMMEDIATELY, without any delay because we're already inside `trx`. + // Conversely, any events scheduled in point (3) above will run AFTER `trx` is done. + // This is as desired. It lets SwitchSignal emit the next signal's initial value before + // emitting subsequent updates to it that might have been triggered by starting it. + // Prior to Airstream 0.15.0 the next signal's initial value would have been missed in such cases. + + nextSignalTry.foreach(Protected.maybeWillStart) + + fireTry(nextSignalTry.flatMap(_.tryNow()), trx) // #Note[onStart,trx,loop] + + // If we're receiving events, this signal is started, so no need to check for that + nextSignalTry.foreach { nextSignal => + nextSignal.addInternalObserver(internalEventObserver, shouldCallMaybeWillStart = false) + } + }) + } } } + // #Note this overrides default SingleParentSignal implementation + override protected def onWillStart(): Unit = { + Protected.maybeWillStart(parent) + currentSignalTry.foreach(Protected.maybeWillStart) + updateCurrentValueFromParent() + } + override protected[this] def onStart(): Unit = { super.onStart() - currentSignalTry.foreach(_.addInternalObserver(internalEventObserver)) + currentSignalTry.foreach(_.addInternalObserver(internalEventObserver, shouldCallMaybeWillStart = false)) } override protected[this] def onStop(): Unit = { diff --git a/src/main/scala/com/raquo/airstream/misc/ChangesEventStream.scala b/src/main/scala/com/raquo/airstream/misc/ChangesEventStream.scala new file mode 100644 index 00000000..8c70c707 --- /dev/null +++ b/src/main/scala/com/raquo/airstream/misc/ChangesEventStream.scala @@ -0,0 +1,64 @@ +package com.raquo.airstream.misc + +import com.raquo.airstream.common.{InternalTryObserver, SingleParentEventStream} +import com.raquo.airstream.core.{Protected, Signal, Transaction} + +import scala.util.Try + +class ChangesEventStream[A]( + override protected[this] val parent: Signal[A] + //emitChangeOnRestart: Boolean +) extends SingleParentEventStream[A, A] with InternalTryObserver[A] { + + //private var maybeLastSeenValue: js.UndefOr[Try[A]] = js.undefined + // + //private var emitSignalValueOnNextStart: js.UndefOr[Try[A]] = js.undefined + + override protected val topoRank: Int = Protected.topoRank(parent) + 1 + + override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { + //maybeLastSeenValue = js.defined(nextValue) + fireTry(nextValue, transaction) + } + + //override protected def onWillStart(): Unit = { + // val maybePrevEmittedValue = maybeLastSeenValue + // super.onWillStart() + // if (emitChangeOnRestart) { + // maybePrevEmittedValue.foreach { prevValue => + // val nextValue = parent.tryNow() + // // I guess what we really want to test here is "did parent emit while the stream was stopped?" + // if (nextValue != prevValue) { // #Note[Sync] We might need to let users bypass this filter eventually (or not, we'll see) + // emitSignalValueOnNextStart = nextValue + // } + // } + // } + //} + // + //override protected[this] def onStart(): Unit = { + // super.onStart() + // emitSignalValueOnNextStart.foreach { nextValue => + // maybeLastSeenValue = nextValue + // // #TODO[Integrity] I'm not sure if such cases should be a whole new transaction, + // // or some kind of high priority post-current-transaction hook. This is weird + // // because normally this stream emits in the same transaction as its parent + // println("changesStream scheduled trx") + // // #TODO[trx,sync] Creating a new transaction is not safe – causes glitches (duh) + // // - I'm not sure if the `emitChangeOnRestart` option can be safely implemented or not. + // // - I tried thinking about it, but couldn't confidently convince myself either way. + // // - At first glance, in the simple case, an observable being started should be able to emit in the CURRENT transaction: + // // since it's just being started, it hasn't emitted yet, so it won't emit more than once. + // // - But that's not actually the case. The observable could have previously been started and stopped in this same transaction. + // // - Or it may be possible for it to emit LATER, after some other ChangeStream (one of its ancestors) is started and emits a + // // transaction. Not sure if that's actually possible. + // // - Could we maybe get around some of this by tracking which transaction the observable was last stopped in, or by using + // // the pending observables mechanism, or something like that? I'm not sure, and I don't have the time to prove it either way. + // new Transaction(fireTry(nextValue, _)) + // emitSignalValueOnNextStart = js.undefined + // } + //} + // + //override protected[this] def onStop(): Unit = { + // super.onStop() + //} +} diff --git a/src/main/scala/com/raquo/airstream/misc/CollectEventStream.scala b/src/main/scala/com/raquo/airstream/misc/CollectEventStream.scala index 84233989..22638751 100644 --- a/src/main/scala/com/raquo/airstream/misc/CollectEventStream.scala +++ b/src/main/scala/com/raquo/airstream/misc/CollectEventStream.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.misc -import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentObservable} -import com.raquo.airstream.core.{EventStream, Protected, Transaction, WritableEventStream} +import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, Protected, Transaction} import scala.util.Try @@ -12,9 +12,9 @@ import scala.util.Try * @param fn Note: guarded against exceptions */ class CollectEventStream[A, B]( - override protected val parent: EventStream[A], + override protected[this] val parent: EventStream[A], fn: A => Option[B], -) extends WritableEventStream[B] with SingleParentObservable[A, B] with InternalNextErrorObserver[A] { +) extends SingleParentEventStream[A, B] with InternalNextErrorObserver[A] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 diff --git a/src/main/scala/com/raquo/airstream/misc/FilterEventStream.scala b/src/main/scala/com/raquo/airstream/misc/FilterEventStream.scala index 15ffa389..9bc9fc5a 100644 --- a/src/main/scala/com/raquo/airstream/misc/FilterEventStream.scala +++ b/src/main/scala/com/raquo/airstream/misc/FilterEventStream.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.misc -import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentObservable} -import com.raquo.airstream.core.{EventStream, Protected, Transaction, WritableEventStream} +import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, Protected, Transaction} import scala.util.Try @@ -13,9 +13,9 @@ import scala.util.Try * @param passes Note: guarded against exceptions */ class FilterEventStream[A]( - override protected val parent: EventStream[A], + override protected[this] val parent: EventStream[A], passes: A => Boolean -) extends WritableEventStream[A] with SingleParentObservable[A, A] with InternalNextErrorObserver[A] { +) extends SingleParentEventStream[A, A] with InternalNextErrorObserver[A] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 diff --git a/src/main/scala/com/raquo/airstream/misc/FoldLeftSignal.scala b/src/main/scala/com/raquo/airstream/misc/FoldLeftSignal.scala index 420a55be..79ac39b2 100644 --- a/src/main/scala/com/raquo/airstream/misc/FoldLeftSignal.scala +++ b/src/main/scala/com/raquo/airstream/misc/FoldLeftSignal.scala @@ -1,8 +1,9 @@ package com.raquo.airstream.misc -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{Observable, Protected, Transaction, WritableSignal} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} +import com.raquo.airstream.core.{Observable, Protected, Signal, Transaction} +import scala.scalajs.js import scala.util.Try /** Note: In folds, failure is often toxic to all subsequent events. @@ -17,13 +18,44 @@ class FoldLeftSignal[A, B]( override protected[this] val parent: Observable[A], makeInitialValue: () => Try[B], fn: (Try[B], Try[A]) => Try[B] -) extends WritableSignal[B] with SingleParentObservable[A, B] with InternalTryObserver[A] { +) extends SingleParentSignal[A, B] with InternalTryObserver[A] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 + private val parentIsSignal: Boolean = parent.isInstanceOf[Signal[_]] + + private var maybeLastSeenParentValue: js.UndefOr[Try[A]] = js.undefined + override protected def onTry(nextParentValue: Try[A], transaction: Transaction): Unit = { + if (parentIsSignal) { + maybeLastSeenParentValue = nextParentValue + } fireTry(fn(tryNow(), nextParentValue), transaction) } - override protected def initialValue: Try[B] = makeInitialValue() + /** #Note: this is called from tryNow(), make sure to avoid infinite loop. */ + override protected def currentValueFromParent(): Try[B] = { + if (parentIsSignal) { + val parentSignal = parent.asInstanceOf[Signal[A @unchecked]] + val maybeUpdatedValue = for { + lastSeenCurrentValue <- maybeLastSeenCurrentValue + lastSeenParentValue <- maybeLastSeenParentValue + } yield { + val nextParentValue = parentSignal.tryNow() + maybeLastSeenParentValue = nextParentValue + if (nextParentValue != lastSeenParentValue) { + fn(lastSeenCurrentValue, nextParentValue) + } else { + lastSeenCurrentValue + } + } + maybeUpdatedValue.getOrElse { + // #TODO Not great that we have a side effect here, but should be ok, I think + maybeLastSeenParentValue = parentSignal.tryNow() + makeInitialValue() + } + } else { + maybeLastSeenCurrentValue.getOrElse(makeInitialValue()) + } + } } diff --git a/src/main/scala/com/raquo/airstream/misc/MapEventStream.scala b/src/main/scala/com/raquo/airstream/misc/MapEventStream.scala index 5ec5c00e..52ccfe24 100644 --- a/src/main/scala/com/raquo/airstream/misc/MapEventStream.scala +++ b/src/main/scala/com/raquo/airstream/misc/MapEventStream.scala @@ -1,8 +1,8 @@ package com.raquo.airstream.misc -import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentObservable} +import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentEventStream} import com.raquo.airstream.core.AirstreamError.ErrorHandlingError -import com.raquo.airstream.core.{Observable, Protected, Transaction, WritableEventStream} +import com.raquo.airstream.core.{Observable, Protected, Transaction} import scala.util.Try @@ -21,10 +21,10 @@ import scala.util.Try * @param recover Note: guarded against exceptions */ class MapEventStream[I, O]( - override protected val parent: Observable[I], + override protected[this] val parent: Observable[I], project: I => O, recover: Option[PartialFunction[Throwable, Option[O]]] -) extends WritableEventStream[O] with SingleParentObservable[I, O] with InternalNextErrorObserver[I] { +) extends SingleParentEventStream[I, O] with InternalNextErrorObserver[I] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 diff --git a/src/main/scala/com/raquo/airstream/misc/MapSignal.scala b/src/main/scala/com/raquo/airstream/misc/MapSignal.scala index 2270bdfa..e72b698b 100644 --- a/src/main/scala/com/raquo/airstream/misc/MapSignal.scala +++ b/src/main/scala/com/raquo/airstream/misc/MapSignal.scala @@ -1,8 +1,8 @@ package com.raquo.airstream.misc -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} import com.raquo.airstream.core.AirstreamError.ErrorHandlingError -import com.raquo.airstream.core.{Protected, Signal, Transaction, WritableSignal} +import com.raquo.airstream.core.{Protected, Signal, Transaction} import scala.util.{Failure, Success, Try} @@ -22,7 +22,7 @@ class MapSignal[I, O]( protected[this] val parent: Signal[I], protected[this] val project: I => O, protected[this] val recover: Option[PartialFunction[Throwable, Option[O]]] -) extends WritableSignal[O] with SingleParentObservable[I, O] with InternalTryObserver[I] { +) extends SingleParentSignal[I, O] with InternalTryObserver[I] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 @@ -52,7 +52,7 @@ class MapSignal[I, O]( ) } - override protected[this] def initialValue: Try[O] = { + override protected def currentValueFromParent(): Try[O] = { val originalValue = parent.tryNow().map(project) originalValue.fold( diff --git a/src/main/scala/com/raquo/airstream/misc/SignalFromEventStream.scala b/src/main/scala/com/raquo/airstream/misc/SignalFromEventStream.scala index 12925363..d1753cc4 100644 --- a/src/main/scala/com/raquo/airstream/misc/SignalFromEventStream.scala +++ b/src/main/scala/com/raquo/airstream/misc/SignalFromEventStream.scala @@ -1,20 +1,32 @@ package com.raquo.airstream.misc -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{EventStream, Protected, Transaction, WritableSignal} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} +import com.raquo.airstream.core.{EventStream, Protected, Transaction} import scala.util.Try class SignalFromEventStream[A]( override protected[this] val parent: EventStream[A], - lazyInitialValue: => Try[A] -) extends WritableSignal[A] with SingleParentObservable[A, A] with InternalTryObserver[A] { + pullInitialValue: => Try[A], + cacheInitialValue: Boolean +) extends SingleParentSignal[A, A] with InternalTryObserver[A] { + + private var hasEmittedEvents = false override protected val topoRank: Int = Protected.topoRank(parent) + 1 - override protected def initialValue: Try[A] = lazyInitialValue + override protected def currentValueFromParent(): Try[A] = { + // #Note See also SplitChildSignal and CustomSignalSource for similar logic + // #Note This can be called from inside tryNow(), so make sure to avoid an infinite loop + if (maybeLastSeenCurrentValue.nonEmpty && (hasEmittedEvents || cacheInitialValue)) { + tryNow() + } else { + pullInitialValue + } + } override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { + hasEmittedEvents = true fireTry(nextValue, transaction) } } diff --git a/src/main/scala/com/raquo/airstream/split/SplitChildSignal.scala b/src/main/scala/com/raquo/airstream/split/SplitChildSignal.scala index 0a561223..5e025b4a 100644 --- a/src/main/scala/com/raquo/airstream/split/SplitChildSignal.scala +++ b/src/main/scala/com/raquo/airstream/split/SplitChildSignal.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.split -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{Protected, Transaction, WritableSignal} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} +import com.raquo.airstream.core.{Protected, Transaction} import com.raquo.airstream.timing.SyncDelayEventStream import scala.util.{Success, Try} @@ -20,7 +20,9 @@ private[airstream] class SplitChildSignal[M[_], A]( override protected[this] val parent: SyncDelayEventStream[M[A]], initial: A, getMemoizedValue: () => Option[A] -) extends WritableSignal[A] with SingleParentObservable[M[A], A] with InternalTryObserver[M[A]] { +) extends SingleParentSignal[M[A], A] with InternalTryObserver[M[A]] { + + private var hasEmittedEvents = false private var maybeInitialTransaction: Option[Transaction] = Transaction.currentTransaction() @@ -28,18 +30,27 @@ private[airstream] class SplitChildSignal[M[_], A]( override protected val topoRank: Int = Protected.topoRank(parent) + 1 - override protected def initialValue: Try[A] = Success(initial) + override protected def currentValueFromParent(): Try[A] = { + // #Note See also SignalFromEventStream for similar logic + // #Note This can be called from inside tryNow(), so make sure to avoid an infinite loop + if (maybeLastSeenCurrentValue.nonEmpty && hasEmittedEvents) { + tryNow() + } else { + Success(initial) + } + } override protected def onTry(nextValue: Try[M[A]], transaction: Transaction): Unit = { getMemoizedValue().foreach { freshMemoizedInput => - // #Note I do think we want to compare both `None` and `Some` cases. + // #Note I do think we want to compare both `None` and `Some` cases of maybeTransaction. // I'm not sure if None is possible, but if it is, this is probably the right thing to do. - // I think None might be possible when evaluating this signal's initial value + // I think None might be possible when evaluating this signal's initial value when starting it if (!droppedDuplicateEvent && maybeInitialTransaction == Transaction.currentTransaction()) { //println(s">>>>> DROPPED EVENT ${freshMemoizedInput}, TRX IS ${maybeInitialTransaction}") maybeInitialTransaction = None droppedDuplicateEvent = true } else { + hasEmittedEvents = true fireTry(Success(freshMemoizedInput), transaction) } } diff --git a/src/main/scala/com/raquo/airstream/split/SplitSignal.scala b/src/main/scala/com/raquo/airstream/split/SplitSignal.scala index 1644d1cc..a2200f6d 100644 --- a/src/main/scala/com/raquo/airstream/split/SplitSignal.scala +++ b/src/main/scala/com/raquo/airstream/split/SplitSignal.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.split -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{Protected, Signal, Transaction, WritableSignal} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} +import com.raquo.airstream.core.{Protected, Signal, Transaction} import com.raquo.airstream.timing.SyncDelayEventStream import scala.collection.mutable @@ -33,11 +33,11 @@ class SplitSignal[M[_], Input, Output, Key]( distinctCompose: Signal[Input] => Signal[Input], project: (Key, Input, Signal[Input]) => Output, splittable: Splittable[M] -) extends WritableSignal[M[Output]] with SingleParentObservable[M[Input], M[Output]] with InternalTryObserver[M[Input]] { +) extends SingleParentSignal[M[Input], M[Output]] with InternalTryObserver[M[Input]] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 - override protected def initialValue: Try[M[Output]] = parent.tryNow().map(memoizedProject) + override protected def currentValueFromParent(): Try[M[Output]] = parent.tryNow().map(memoizedProject) private[this] val memoized: mutable.Map[Key, (Input, Output)] = mutable.Map.empty diff --git a/src/main/scala/com/raquo/airstream/state/SourceVar.scala b/src/main/scala/com/raquo/airstream/state/SourceVar.scala index 8539bef4..68dc465a 100644 --- a/src/main/scala/com/raquo/airstream/state/SourceVar.scala +++ b/src/main/scala/com/raquo/airstream/state/SourceVar.scala @@ -13,7 +13,7 @@ class SourceVar[A] private[state](initial: Try[A]) extends Var[A] { private[this] var currentValue: Try[A] = initial /** VarSignal is a private type, do not expose it */ - private[this] val _varSignal = new VarSignal[A](initialValue = currentValue) + private[this] val _varSignal = new VarSignal[A](initial = currentValue) override private[state] def underlyingVar: SourceVar[_] = this diff --git a/src/main/scala/com/raquo/airstream/state/Val.scala b/src/main/scala/com/raquo/airstream/state/Val.scala index fc50302b..bb2e78e7 100644 --- a/src/main/scala/com/raquo/airstream/state/Val.scala +++ b/src/main/scala/com/raquo/airstream/state/Val.scala @@ -2,14 +2,18 @@ package com.raquo.airstream.state import com.raquo.airstream.core.WritableSignal -import scala.util.{ Success, Try } +import scala.util.{Success, Try} -class Val[A](override protected[this] val initialValue: Try[A]) extends WritableSignal[A] with StrictSignal[A] { +class Val[A](constantValue: Try[A]) extends WritableSignal[A] with StrictSignal[A] { override protected val topoRank: Int = 1 /** Value never changes, so we can use a simplified implementation */ - override def tryNow(): Try[A] = initialValue + override def tryNow(): Try[A] = constantValue + + override protected def currentValueFromParent(): Try[A] = constantValue + + override protected def onWillStart(): Unit = () // noop } object Val { diff --git a/src/main/scala/com/raquo/airstream/state/VarSignal.scala b/src/main/scala/com/raquo/airstream/state/VarSignal.scala index caed33fc..bcaa678b 100644 --- a/src/main/scala/com/raquo/airstream/state/VarSignal.scala +++ b/src/main/scala/com/raquo/airstream/state/VarSignal.scala @@ -1,6 +1,6 @@ package com.raquo.airstream.state -import com.raquo.airstream.core.{ Transaction, WritableSignal } +import com.raquo.airstream.core.{Transaction, WritableSignal} import scala.util.Try @@ -12,12 +12,14 @@ import scala.util.Try * (see StrictSignal). */ private[state] class VarSignal[A] private[state]( - override protected[this] val initialValue: Try[A] + initial: Try[A] ) extends WritableSignal[A] with StrictSignal[A] { /** SourceVar does not directly depend on other observables, so it breaks the graph. */ override protected val topoRank: Int = 1 + setCurrentValue(initial) + /** Note: we do not check if isStarted() here, this is how we ensure that this * signal's current value stays up to date. If this signal is stopped, this * value will not be propagated anywhere further though. @@ -25,4 +27,8 @@ private[state] class VarSignal[A] private[state]( private[state] def onTry(nextValue: Try[A], transaction: Transaction): Unit = { fireTry(nextValue, transaction) } + + override protected def currentValueFromParent(): Try[A] = tryNow() // noop + + override protected def onWillStart(): Unit = () // noop } diff --git a/src/main/scala/com/raquo/airstream/timing/DebounceEventStream.scala b/src/main/scala/com/raquo/airstream/timing/DebounceEventStream.scala index 75d1f3c6..bcb292d0 100644 --- a/src/main/scala/com/raquo/airstream/timing/DebounceEventStream.scala +++ b/src/main/scala/com/raquo/airstream/timing/DebounceEventStream.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.timing -import com.raquo.airstream.common.{ InternalTryObserver, SingleParentObservable } -import com.raquo.airstream.core.{ EventStream, Transaction, WritableEventStream } +import com.raquo.airstream.common.{InternalTryObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, Transaction} import scala.scalajs.js import scala.scalajs.js.timers.SetTimeoutHandle @@ -20,7 +20,7 @@ import scala.util.Try class DebounceEventStream[A]( override protected[this] val parent: EventStream[A], intervalMs: Int -) extends WritableEventStream[A] with SingleParentObservable[A, A] with InternalTryObserver[A] { +) extends SingleParentEventStream[A, A] with InternalTryObserver[A] { private[this] var maybeLastTimeoutHandle: js.UndefOr[SetTimeoutHandle] = js.undefined diff --git a/src/main/scala/com/raquo/airstream/timing/DelayEventStream.scala b/src/main/scala/com/raquo/airstream/timing/DelayEventStream.scala index 9fa7ef23..8cf60e8c 100644 --- a/src/main/scala/com/raquo/airstream/timing/DelayEventStream.scala +++ b/src/main/scala/com/raquo/airstream/timing/DelayEventStream.scala @@ -1,15 +1,15 @@ package com.raquo.airstream.timing -import com.raquo.airstream.common.{ InternalNextErrorObserver, SingleParentObservable } -import com.raquo.airstream.core.{ EventStream, Transaction, WritableEventStream } +import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, Transaction} import scala.scalajs.js import scala.scalajs.js.timers.SetTimeoutHandle class DelayEventStream[A]( - override protected val parent: EventStream[A], + override protected[this] val parent: EventStream[A], delayMs: Int -) extends WritableEventStream[A] with SingleParentObservable[A, A] with InternalNextErrorObserver[A] { +) extends SingleParentEventStream[A, A] with InternalNextErrorObserver[A] { /** Async stream, so reset rank */ override protected val topoRank: Int = 1 diff --git a/src/main/scala/com/raquo/airstream/timing/FutureEventStream.scala b/src/main/scala/com/raquo/airstream/timing/FutureEventStream.scala index 300315ca..ced03c7e 100644 --- a/src/main/scala/com/raquo/airstream/timing/FutureEventStream.scala +++ b/src/main/scala/com/raquo/airstream/timing/FutureEventStream.scala @@ -1,6 +1,6 @@ package com.raquo.airstream.timing -import com.raquo.airstream.core.{ Transaction, WritableEventStream } +import com.raquo.airstream.core.{Transaction, WritableEventStream} import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue // #TODO #nc remove this in 15.0.0 import scala.concurrent.Future @@ -11,15 +11,12 @@ import scala.concurrent.Future * future was resolved, except as provided by `emitIfFutureCompleted`. * * @param future Note: guarded against failures - * @param emitIfFutureCompleted If false, this stream will emit an event when it's initialized with - * an already completed future. Generally you should avoid this and use - * [[FutureSignal]] instead. */ -class FutureEventStream[A](future: Future[A], emitIfFutureCompleted: Boolean) extends WritableEventStream[A] { +class FutureEventStream[A](future: Future[A]) extends WritableEventStream[A] { override protected val topoRank: Int = 1 - if (!future.isCompleted || emitIfFutureCompleted) { + if (!future.isCompleted) { // @TODO[API] Do we need "isStarted" filter on these? Doesn't seem to affect anything for now... future.onComplete(_.fold( nextError => { @@ -32,4 +29,6 @@ class FutureEventStream[A](future: Future[A], emitIfFutureCompleted: Boolean) ex } )) } + + override protected def onWillStart(): Unit = () // noop } diff --git a/src/main/scala/com/raquo/airstream/timing/FutureSignal.scala b/src/main/scala/com/raquo/airstream/timing/FutureSignal.scala index 02a140c9..a3f1bdd9 100644 --- a/src/main/scala/com/raquo/airstream/timing/FutureSignal.scala +++ b/src/main/scala/com/raquo/airstream/timing/FutureSignal.scala @@ -1,51 +1,46 @@ package com.raquo.airstream.timing -import com.raquo.airstream.core.{ Transaction, WritableSignal } -import com.raquo.airstream.state.StrictSignal +import com.raquo.airstream.core.{Transaction, WritableSignal} import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue // #TODO #nc remove this in 15.0.0 import scala.concurrent.Future -import scala.util.{ Success, Try } - -// @TODO confirm that memory management is ok here between the future and this signal. - -/** This signal behaves a bit differently than other signals typically do: - * it keeps track of state regardless of whether it is started. - * This is possible because this case requires no special memory management. - * - * Note that being a StrictSignal, this exposes `now` and `tryNow` methods, - * however if the `future` was not yet completed when this signal was created, - * this signal's current value will be updated *asynchronously* after the future - * has completed. - */ -class FutureSignal[A]( - future: Future[A] -) extends WritableSignal[Option[A]] with StrictSignal[Option[A]] { +import scala.util.{Success, Try} - override protected val topoRank: Int = 1 - - override protected[this] def initialValue: Try[Option[A]] = { - - val futureValue = future.value.fold[Try[Option[A]]]( - Success(None) - )( - value => value.map(Some(_)) - ) +class FutureSignal[A](future: Future[A]) extends WritableSignal[Option[A]] { - // Subscribing to this signal, or requesting now() or tryNow() will trigger initialValue - // evaluation, which will register an onComplete callback on the future if it's not resolved yet. + override protected val topoRank: Int = 1 - // @nc @TODO If implementing https://github.com/raquo/Airstream/issues/43 - // This needs to be adjusted to avoid more than one onComplete calls per instance of signal. - // Just add a boolean (don't look at tryNow, because that might cause infinite loop) + private var futureSubscribed = false - if (!future.isCompleted) { - future.onComplete(value => { - //println(s"> init trx from FutureSignal($value)") - new Transaction(fireTry(value.map(Some(_)), _)) - }) + override protected def currentValueFromParent(): Try[Option[A]] = { + future.value match { + case Some(value) => value.map(Some(_)) + case None => Success(None) } + } - futureValue + override protected def onWillStart(): Unit = { + // #TODO[sync] Not sure if we need this line or not. + setCurrentValue(currentValueFromParent()) + + if (!futureSubscribed && !future.isCompleted) { + futureSubscribed = true + // #Note onWillStart must not create transactions / emit values, but this is ok here + // because onComplete is always asynchronous in ScalaJS, so any value will be emitted + // long after the onWillStart / onStart chain has finished. + // #Note fireTry sets current value even if the signal has no observers + future.onComplete { value => + val nextValue = value.map(Some(_)) + if (nextValue.map(_.map(_ => ())) != tryNow().map(_.map(_ => ()))) { + // #TODO[sync] If somehow the signal's current value has already been updated with the Future's resolved value, + // we don't want to emit a separate event. The `_.map(_ => ())` trick is just to avoid comparing the resolved + // values using `==` – that could be expensive, and it's not necessary since we know that a resolved Future + // can never change its value. + // I'm not actually sure if this condition is necessary, it would have to be some weird timing. + //println(s"> init trx from FutureSignal($value)") + new Transaction(fireTry(nextValue, _)) // #Note[onStart,trx,async] + } + } + } } } diff --git a/src/main/scala/com/raquo/airstream/timing/PeriodicEventStream.scala b/src/main/scala/com/raquo/airstream/timing/PeriodicEventStream.scala index 5333989c..fe36ab1c 100644 --- a/src/main/scala/com/raquo/airstream/timing/PeriodicEventStream.scala +++ b/src/main/scala/com/raquo/airstream/timing/PeriodicEventStream.scala @@ -1,9 +1,9 @@ package com.raquo.airstream.timing -import com.raquo.airstream.core.{ Transaction, WritableEventStream } +import com.raquo.airstream.core.{Transaction, WritableEventStream} import scala.scalajs.js -import scala.util.{ Failure, Success, Try } +import scala.util.{Failure, Success, Try} /** @param next (currentState => (nextState, nextIntervalMs) * Note: guarded against exceptions. @@ -42,7 +42,7 @@ class PeriodicEventStream[A]( } private def tick(isStarting: Boolean): Unit = { - new Transaction(trx => { + new Transaction(trx => { // #Note[onStart,trx,async] if (emitInitial || !isStarting) { fireValue(currentValue, trx) } @@ -60,10 +60,12 @@ class PeriodicEventStream[A]( case Success(None) => resetTo(initial, tickNext = false) case Failure(err) => - new Transaction(fireError(err, _)) + new Transaction(fireError(err, _)) // #Note[onStart,trx,async] } } + override protected def onWillStart(): Unit = () // noop + override protected[this] def onStart(): Unit = { super.onStart() tick(isStarting = true) diff --git a/src/main/scala/com/raquo/airstream/timing/SyncDelayEventStream.scala b/src/main/scala/com/raquo/airstream/timing/SyncDelayEventStream.scala index f212d97e..e89a10ce 100644 --- a/src/main/scala/com/raquo/airstream/timing/SyncDelayEventStream.scala +++ b/src/main/scala/com/raquo/airstream/timing/SyncDelayEventStream.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.timing -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{Observable, Protected, SyncObservable, Transaction, WritableEventStream} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentEventStream} +import com.raquo.airstream.core.{Observable, Protected, SyncObservable, Transaction} import scala.scalajs.js import scala.util.Try @@ -14,7 +14,7 @@ import scala.util.Try class SyncDelayEventStream[A] ( override protected[this] val parent: Observable[A], after: Observable[_] -) extends WritableEventStream[A] with SingleParentObservable[A, A] with InternalTryObserver[A] with SyncObservable[A] { +) extends SingleParentEventStream[A, A] with InternalTryObserver[A] with SyncObservable[A] { private[this] var maybePendingValue: js.UndefOr[Try[A]] = js.undefined diff --git a/src/main/scala/com/raquo/airstream/timing/ThrottleEventStream.scala b/src/main/scala/com/raquo/airstream/timing/ThrottleEventStream.scala index cae1579f..6a676a30 100644 --- a/src/main/scala/com/raquo/airstream/timing/ThrottleEventStream.scala +++ b/src/main/scala/com/raquo/airstream/timing/ThrottleEventStream.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.timing -import com.raquo.airstream.common.{ InternalTryObserver, SingleParentObservable } -import com.raquo.airstream.core.{ EventStream, Transaction, WritableEventStream } +import com.raquo.airstream.common.{InternalTryObserver, SingleParentEventStream} +import com.raquo.airstream.core.{EventStream, Transaction} import scala.scalajs.js import scala.scalajs.js.timers.SetTimeoutHandle @@ -22,7 +22,7 @@ class ThrottleEventStream[A]( override protected[this] val parent: EventStream[A], intervalMs: Int, leading: Boolean -) extends WritableEventStream[A] with SingleParentObservable[A, A] with InternalTryObserver[A] { +) extends SingleParentEventStream[A, A] with InternalTryObserver[A] { private[this] var lastEmittedEventMs: js.UndefOr[Double] = js.undefined diff --git a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala index 021336e8..bebd220c 100644 --- a/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala +++ b/src/main/scala/com/raquo/airstream/web/AjaxEventStream.scala @@ -48,6 +48,19 @@ class AjaxEventStream( private var pendingRequest: Option[dom.XMLHttpRequest] = None + /** This stream will emit at most one event per request regardless of the outcome. + * + * You need to introspect the result to determine whether the request + * succeeded, failed, timed out, or was aborted. + */ + lazy val completeEvents: EventStream[dom.XMLHttpRequest] = { + this.recover { + case err: AjaxStreamError => Some(err.xhr) + } + } + + override protected def onWillStart(): Unit = () + override protected[this] def onStart(): Unit = { val request = AjaxEventStream.initRequest(timeoutMs, withCredentials, responseType) @@ -125,17 +138,6 @@ class AjaxEventStream( super.onStart() } - /** This stream will emit at most one event per request regardless of the outcome. - * - * You need to introspect the result to determine whether the request - * succeeded, failed, timed out, or was aborted. - */ - lazy val completeEvents: EventStream[dom.XMLHttpRequest] = { - this.recover { - case err: AjaxStreamError => Some(err.xhr) - } - } - override protected[this] def onStop(): Unit = { pendingRequest = None super.onStop() diff --git a/src/test/scala/com/raquo/airstream/DebugSpec.scala b/src/test/scala/com/raquo/airstream/DebugSpec.scala index a0eb483e..cb6e2cad 100644 --- a/src/test/scala/com/raquo/airstream/DebugSpec.scala +++ b/src/test/scala/com/raquo/airstream/DebugSpec.scala @@ -205,17 +205,17 @@ class DebugSpec extends UnitSpec with BeforeAndAfter { // #Note: we write out `ev` excessively to make sure that type inference works val signal = v.signal - .debugSpyInitialEval(ev => Calculation.log("var.signal-initial", calculations)(ev)) + .debugSpyEvalFromParent(ev => Calculation.log("var.signal-eval-from-parent", calculations)(ev)) .debugSpy(ev => Calculation.log("var.signal", calculations)(ev)) val signal1 = signal - .debugSpyInitialEval(ev => Calculation.log("signal-1-initial", calculations)(ev)) + .debugSpyEvalFromParent(ev => Calculation.log("signal-1-eval-from-parent", calculations)(ev)) .debugSpyStarts(_ => Calculation.log("signal-1-start", calculations)(Success(-1))) .debugSpy(ev => Calculation.log("signal-1", calculations)(ev)) .debugSpyStops(() => Calculation.log("signal-1-stop", calculations)(Success(-1))) val signal2 = signal - .debugSpyInitialEval(ev => Calculation.log("signal-2-initial", calculations)(ev)) + .debugSpyEvalFromParent(ev => Calculation.log("signal-2-eval-from-parent", calculations)(ev)) .debugSpyEvents(ev => Calculation.log("signal-2", calculations)(Success(ev))) .debugSpyErrors(err => Calculation.log("signal-2", calculations)(Failure(err))) .debugSpyLifecycle( @@ -245,8 +245,8 @@ class DebugSpec extends UnitSpec with BeforeAndAfter { // Order of logs is affected by order of debug statements above. It's slightly different for signal1 and signal2 assert(calculations.toList == List( - Calculation("var.signal-initial", Success(1)), - Calculation("signal-1-initial", Success(1)), + Calculation("var.signal-eval-from-parent", Success(1)), + Calculation("signal-1-eval-from-parent", Success(1)), Calculation("obs-1", Success(1)), Calculation("var.signal", Success(1)), Calculation("signal-1-start", Success(-1)), @@ -310,11 +310,13 @@ class DebugSpec extends UnitSpec with BeforeAndAfter { signal2.addObserver(obs22) assert(calculations.toList == List( + Calculation("var.signal-eval-from-parent", Success(3)), + Calculation("signal-1-eval-from-parent", Success(3)), Calculation("obs-1", Success(3)), // receive current value (initial value was already evaluated) Calculation("var.signal", Success(3)), Calculation("signal-1-start", Success(-1)), Calculation("signal-1", Success(3)), - Calculation("signal-2-initial", Success(3)), + Calculation("signal-2-eval-from-parent", Success(3)), Calculation("obs-21", Success(3)), Calculation("signal-2", Success(3)), Calculation("signal-2-start", Success(-1)), @@ -580,11 +582,11 @@ class DebugSpec extends UnitSpec with BeforeAndAfter { it("observable debugger type inference") { - val _: Observable[String] = EventStream.fromValue("a").debugSpy(_ => ()).debugLogStarts + EventStream.fromValue("a").debugSpy(_ => ()).debugLogStarts: Observable[String] - val _: EventStream[String] = EventStream.fromValue("a").debugSpy(_ => ()).debugLogStarts + EventStream.fromValue("a").debugSpy(_ => ()).debugLogStarts: EventStream[String] - val _: Signal[String] = Val("a").debugSpy(_ => ()).debugLogStarts + Val("a").debugSpy(_ => ()).debugLogStarts: Signal[String] } it("observable debugger error") { diff --git a/src/test/scala/com/raquo/airstream/combine/SampleCombineEventStream2Spec.scala b/src/test/scala/com/raquo/airstream/combine/SampleCombineEventStream2Spec.scala index 8efeffc7..d1a319cb 100644 --- a/src/test/scala/com/raquo/airstream/combine/SampleCombineEventStream2Spec.scala +++ b/src/test/scala/com/raquo/airstream/combine/SampleCombineEventStream2Spec.scala @@ -93,11 +93,14 @@ class SampleCombineEventStream2Spec extends UnitSpec { subCombined.kill() sampledSignal.addObserver(signalObserver) - calculations shouldEqual mutable.Buffer() + calculations shouldEqual mutable.Buffer( + Calculation("signal", 100) + ) effects shouldEqual mutable.Buffer( Effect("signal", 100) ) + calculations.clear() effects.clear() // -- diff --git a/src/test/scala/com/raquo/airstream/combine/SampleCombineSignal2Spec.scala b/src/test/scala/com/raquo/airstream/combine/SampleCombineSignal2Spec.scala index c1e6235e..f644314d 100644 --- a/src/test/scala/com/raquo/airstream/combine/SampleCombineSignal2Spec.scala +++ b/src/test/scala/com/raquo/airstream/combine/SampleCombineSignal2Spec.scala @@ -98,11 +98,14 @@ class SampleCombineSignal2Spec extends UnitSpec { subCombined.kill() sampledSignal.addObserver(sampledObserver) - calculations shouldEqual mutable.Buffer() + calculations shouldEqual mutable.Buffer( + Calculation("sampled", 20) + ) effects shouldEqual mutable.Buffer( Effect("sampled", 20) ) + calculations.clear() effects.clear() // -- @@ -123,11 +126,15 @@ class SampleCombineSignal2Spec extends UnitSpec { combinedSignal.addObserver(combinedObserver) - calculations shouldEqual mutable.Buffer() + calculations shouldEqual mutable.Buffer( + Calculation("sampling", 300), + Calculation("combined", 330) + ) effects shouldEqual mutable.Buffer( - Effect("combined", 320) // @TODO[API] This could be 330 if we implement https://github.com/raquo/Airstream/issues/43 + Effect("combined", 330) ) + calculations.clear() effects.clear() // -- diff --git a/src/test/scala/com/raquo/airstream/core/PullResetSignalSpec.scala b/src/test/scala/com/raquo/airstream/core/PullResetSignalSpec.scala new file mode 100644 index 00000000..ac9808cb --- /dev/null +++ b/src/test/scala/com/raquo/airstream/core/PullResetSignalSpec.scala @@ -0,0 +1,873 @@ +package com.raquo.airstream.core + +import com.raquo.airstream.UnitSpec +import com.raquo.airstream.eventbus.EventBus +import com.raquo.airstream.fixtures.{Calculation, Effect, TestableOwner} +import com.raquo.airstream.state.Var + +import scala.collection.mutable + +class PullResetSignalSpec extends UnitSpec { + + it("ChangesStream & startWith") { + + test(CACHE_INITIAL_VALUE = false/*, EMIT_CHANGE_ON_RESTART = true*/) + //test(CACHE_INITIAL_VALUE = false, EMIT_CHANGE_ON_RESTART = false) + //test(CACHE_INITIAL_VALUE = true, EMIT_CHANGE_ON_RESTART = true) + test(CACHE_INITIAL_VALUE = true/*, EMIT_CHANGE_ON_RESTART = false*/) + + def test(CACHE_INITIAL_VALUE: Boolean/*, EMIT_CHANGE_ON_RESTART: Boolean*/): Unit = { + + withClue(s"Test with cacheInitialValue=$CACHE_INITIAL_VALUE" /* + s"emitChangeOnRestart=$EMIT_CHANGE_ON_RESTART"*/) { + + implicit val testOwner: TestableOwner = new TestableOwner + + val effects = mutable.Buffer[Effect[Int]]() + val calculations = mutable.Buffer[Calculation[Int]]() + + var downInitial = 0 + + val $var = Var(1) + + val upSignal = $var + .signal + .setDisplayName("varSignal") + .map(identity) + .setDisplayName("varSignal.map(identity)") + .map(Calculation.log("up", calculations)) + .setDisplayName("varSignal.map(identity).map") + + val changesStream = upSignal.changes //(if (EMIT_CHANGE_ON_RESTART) upSignal.changesEmitChangeOnRestart else upSignal.changes) + .setDisplayName("upSignal.changes") + .map(Calculation.log("changes", calculations)) + .setDisplayName("upSignal.changes.map") + + val downSignal = changesStream + .startWith(downInitial, cacheInitialValue = CACHE_INITIAL_VALUE) + .setDisplayName("changes.startWith") + .map(Calculation.log("down", calculations)) + .setDisplayName("downSignal") + + val upObs = Observer(Effect.log("up-obs", effects)) + val changesObs = Observer(Effect.log("changes-obs", effects)) + val downObs = Observer[Int](v => { + Effect.log("down-obs", effects)(v) + }) + + // -- + + upSignal.addObserver(upObs) + + val downSub1 = downSignal.addObserver(downObs) + + calculations shouldBe mutable.Buffer( + Calculation("up", 1), + Calculation("down", 0) + ) + effects shouldBe mutable.Buffer( + Effect("up-obs", 1), + Effect("down-obs", 0), + ) + calculations.clear() + effects.clear() + + // -- + + downSub1.kill() + + downInitial = 10 + + val downSub2 = downSignal.addObserver(downObs) + + if (CACHE_INITIAL_VALUE) { + calculations shouldBe mutable.Buffer( + Calculation("down", 0) + ) + effects shouldBe mutable.Buffer( + Effect("down-obs", 0), + ) + } else { + calculations shouldBe mutable.Buffer( + Calculation("down", 10) + ) + effects shouldBe mutable.Buffer( + Effect("down-obs", 10), + ) + } + + calculations.clear() + effects.clear() + + // -- + + $var.set(2) + + calculations shouldBe mutable.Buffer( + Calculation("up", 2), + Calculation("changes", 2), + Calculation("down", 2) + ) + effects shouldBe mutable.Buffer( + Effect("up-obs", 2), + Effect("down-obs", 2) + ) + calculations.clear() + effects.clear() + + // -- again, because there is no _.distinct now + + $var.set(2) + + calculations shouldBe mutable.Buffer( + Calculation("up", 2), + Calculation("changes", 2), + Calculation("down", 2) + ) + effects shouldBe mutable.Buffer( + Effect("up-obs", 2), + Effect("down-obs", 2) + ) + calculations.clear() + effects.clear() + + // -- + + downSub2.kill() + + downInitial = 20 + + val downSub3 = downSignal.addObserver(downObs) + + calculations shouldBe mutable.Buffer( + Calculation("down", 2) + ) + effects shouldBe mutable.Buffer( + Effect("down-obs", 2) + ) + + calculations.clear() + effects.clear() + + // -- + + downSub3.kill() + + $var.set(3) + + calculations shouldBe mutable.Buffer( + Calculation("up", 3) + ) + effects shouldBe mutable.Buffer( + Effect("up-obs", 3) + ) + calculations.clear() + effects.clear() + + // -- + + downSignal.addObserver(downObs) + + //if (EMIT_CHANGE_ON_RESTART) { + // // Emitting `2` is undesirable here, which is why `emitChangeOnRestart` is off by default. + // // It would be great if we could get only `3`, but it's not possible to fetch this without + // // processing the event through the streams, because it can have a) asynchronous and + // // b) filtering components to it, and there's no way for us to simulate that without actually + // // running that logic. We can only do this for signals because signals always have a current + // // value and are always synchronous. + // + // calculations shouldBe mutable.Buffer( + // Calculation("down", 2), + // Calculation("changes", 3), + // Calculation("down", 3) + // ) + // effects shouldBe mutable.Buffer( + // Effect("down-obs", 2), + // Effect("down-obs", 3) + // ) + //} else { + // The signal re-starts with an old value because it can't pull a fresh value from the streams + calculations shouldBe mutable.Buffer( + Calculation("down", 2) + ) + effects shouldBe mutable.Buffer( + Effect("down-obs", 2), + ) + //} + + calculations.clear() + effects.clear() + + } + } + } + + it("ChangesStream / StartWith potential glitch") { + + // #Note originally this was intended to test emitChangeOnRestart parameter of .changes, + // but i couldn't make that option glitch-free so we had to leave it out for now. + // There's a long comment about this in ChangesStream + + // #TODO Because of the above, I feel like this test is now pretty redundant... + // Very similar to the "ChangesStream & startWith" test above + + implicit val testOwner: TestableOwner = new TestableOwner + + val log = mutable.Buffer[String]() + + val $v = Var(1) + + def $changes = $v + .signal.setDisplayName("VarSignal") + .changes.setDisplayName("VarSignal.changes") + + val $isPositive = $changes.map { num => + val isPositive = num > 0 + log += s"$num isPositive = $isPositive" + isPositive + }.setDisplayName("IsPositive") + + val $isEven = $changes.map { num => + val isEven = num % 2 == 0 + log += s"$num isEven = $isEven" + isEven + }.setDisplayName("IsEven") + + val $combined = $changes.combineWithFn($isPositive, $isEven) { (num, isPositive, isEven) => + log += s"$num isPositive = $isPositive, isEven = $isEven" + (isPositive, isEven) + }.setDisplayName("Combined") + + val $result = $combined.startWith(0).setDisplayName("Result") + + val sub1 = $result.addObserver(Observer.empty) + + log.toList shouldBe Nil + + // -- + + $v.set(2) + + log.toList shouldBe List( + "2 isPositive = true", + "2 isEven = true", + "2 isPositive = true, isEven = true" + ) + log.clear() + + // -- + + $v.set(3) + + log.toList shouldBe List( + "3 isPositive = true", + "3 isEven = false", + "3 isPositive = true, isEven = false" + ) + log.clear() + + // -- + + sub1.kill() + + $v.set(-4) + + log.toList shouldBe List() + + // -- + + $combined.addObserver(Observer.empty) + + log.toList shouldBe Nil + + // -- + + $v.set(-6) + + log.toList shouldBe List( + "-6 isPositive = false", + "-6 isEven = true", + "-6 isPositive = false, isEven = true" + ) + log.clear() + + } + + + it("CombineEventStreamN") { + + implicit val testOwner: TestableOwner = new TestableOwner + + case class T1(v: Int) + case class T2(v: Int) + + val bus1 = new EventBus[T1]() + val bus2 = new EventBus[T2]() + + val combinedStream = EventStream.combine(bus1, bus2) + + val effects = mutable.Buffer[(T1, T2)]() + + val observer = Observer[(T1, T2)](effects += _) + + // -- + + effects.toList shouldBe Nil + + // -- + + val sub1 = combinedStream.addObserver(observer) + + effects.toList shouldBe Nil + + // -- + + bus1.writer.onNext(T1(0)) + + effects.toList shouldBe Nil + + // -- + + bus2.writer.onNext(T2(0)) + + effects.toList shouldEqual List( + (T1(0), T2(0)) + ) + effects.clear() + + // -- + + bus2.writer.onNext(T2(1)) + + effects.toList shouldEqual List( + (T1(0), T2(1)) + ) + effects.clear() + + // -- + + bus1.writer.onNext(T1(10)) + + effects.toList shouldEqual List( + (T1(10), T2(1)) + ) + effects.clear() + + // -- + + sub1.kill() + + bus2.writer.onNext(T2(2)) + + effects.toList shouldEqual Nil + + // -- + + combinedStream.addObserver(observer) + + effects.toList shouldEqual Nil + + // -- + + bus1.writer.onNext(T1(20)) + + effects.toList shouldEqual List( + (T1(20), T2(1)) + ) + effects.clear() + + // -- + + bus2.writer.onNext(T2(3)) + + effects.toList shouldEqual List( + (T1(20), T2(3)) + ) + effects.clear() + + } + + it("CombineSignalN") { + + implicit val testOwner: TestableOwner = new TestableOwner + + case class T1(v: Int) + case class T2(v: Int) + + val calculations = mutable.Buffer[Calculation[Int]]() + val effects = mutable.Buffer[(T1, T2)]() + + val $var1 = Var(T1(0)) + val $var2 = Var(T2(0)) + + val combinedSignal = $var1 + .signal + .setDisplayName("var1.signal") + .map { t => + calculations += Calculation("signal1", t.v) + t + } + .setDisplayName("var1.signal.map") + .combineWith( + $var2 + .signal + .setDisplayName("var2.signal") + .map { t => + calculations += Calculation("signal2", t.v) + t + } + .setDisplayName("var2.signal.map") + ) + .setDisplayName("combineSignal") + + val observer = Observer[(T1, T2)](effects += _) + + // -- + + calculations.toList shouldBe Nil + effects.toList shouldBe Nil + + // -- + + val sub1 = combinedSignal.addObserver(observer) + + calculations.toList shouldBe List( + Calculation("signal1", 0), + Calculation("signal2", 0), + ) + effects.toList shouldBe List( + (T1(0), T2(0)) + ) + + calculations.clear() + effects.clear() + + + // -- + + $var1.writer.onNext(T1(1)) + + calculations.toList shouldBe List( + Calculation("signal1", 1) + ) + effects.toList shouldBe List( + (T1(1), T2(0)) + ) + + calculations.clear() + effects.clear() + + // -- + + $var2.writer.onNext(T2(2)) + + calculations.toList shouldBe List( + Calculation("signal2", 2) + ) + effects.toList shouldBe List( + (T1(1), T2(2)) + ) + calculations.clear() + effects.clear() + + // -- + + sub1.kill() + + $var2.writer.onNext(T2(3)) + + calculations.toList shouldBe Nil + effects.toList shouldBe Nil + + // -- + + combinedSignal.addObserver(observer) + + calculations.toList shouldBe List( + Calculation("signal1", 1), + Calculation("signal2", 3) + ) + effects.toList shouldBe List( + (T1(1), T2(3)) + ) + + calculations.clear() + effects.clear() + + // -- + + + $var1.writer.onNext(T1(10)) + + calculations.toList shouldBe List( + Calculation("signal1", 10) + ) + effects.toList shouldEqual List( + (T1(10), T2(3)) + ) + + calculations.clear() + effects.clear() + + } + + it("MapSignal pull") { + + implicit val testOwner: TestableOwner = new TestableOwner + + val effects = mutable.Buffer[Effect[Int]]() + val calculations = mutable.Buffer[Calculation[Int]]() + + val $var = Var(1) + + val signal = $var + .signal + .map(Calculation.log("signal", calculations)) + + val signal_x10 = signal + .map(_ * 10) + .map(Calculation.log("signal_x10", calculations)) + + val obs = Observer(Effect.log("obs", effects)) + val obs_x10 = Observer(Effect.log("obs_x10", effects)) + + // -- + + signal.addObserver(obs) + + calculations shouldBe mutable.Buffer( + Calculation("signal", 1) + ) + effects shouldBe mutable.Buffer( + Effect("obs", 1) + ) + calculations.clear() + effects.clear() + + // -- + + val sub1_x10 = signal_x10.addObserver(obs_x10) + + calculations shouldBe mutable.Buffer( + Calculation("signal_x10", 10) + ) + effects shouldBe mutable.Buffer( + Effect("obs_x10", 10) + ) + + calculations.clear() + effects.clear() + + // -- + + $var.set(2) + + calculations shouldBe mutable.Buffer( + Calculation("signal", 2), + Calculation("signal_x10", 20) + ) + effects shouldBe mutable.Buffer( + Effect("obs", 2), + Effect("obs_x10", 20) + ) + + calculations.clear() + effects.clear() + + // -- + + sub1_x10.kill() + + $var.set(3) + + calculations shouldBe mutable.Buffer( + Calculation("signal", 3) + ) + effects shouldBe mutable.Buffer( + Effect("obs", 3) + ) + calculations.clear() + effects.clear() + + // -- + + signal_x10.addObserver(obs_x10) + + calculations shouldBe mutable.Buffer( + Calculation("signal_x10", 30) + ) + effects shouldBe mutable.Buffer( + Effect("obs_x10", 30) + ) + calculations.clear() + effects.clear() + + } + + it("Flattened signals pull check") { + + implicit val owner: TestableOwner = new TestableOwner + + val calculations = mutable.Buffer[Calculation[String]]() + + val outerBus = new EventBus[Int].setDisplayName("OuterBus") + + val smallSignal = EventStream + .fromSeq("small-1" :: "small-2" :: Nil, emitOnce = true) + .setDisplayName("SmallSeqStream") + .startWith("small-0") + .setDisplayName("SmallSignal") + + val bigSignal = EventStream + .fromSeq("big-1" :: "big-2" :: Nil, emitOnce = true) + .setDisplayName("BigSeqStream") + .startWith("big-0") + .setDisplayName("BigSignal") + + val flatSignal = outerBus + .events + .startWith(0) + .setDisplayName("OuterBus.startWith") + .map { + case i if i >= 10 => bigSignal + case _ => smallSignal + } + .setDisplayName("MetaSignal") + .flatten + .setDisplayName("FlatSignal") + .map(Calculation.log("flat", calculations)) + .setDisplayName("FlatSignal--LOG") + + // -- + + flatSignal.addObserver(Observer.empty) + + assert(calculations.toList == List( + Calculation("flat", "small-0"), + Calculation("flat", "small-1"), + Calculation("flat", "small-2"), + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(1) + + assert(calculations.toList == List( + Calculation("flat", "small-2") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(2) + + assert(calculations.toList == List( + Calculation("flat", "small-2") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(10) // #Note switch to big + + assert(calculations.toList == List( + Calculation("flat", "big-0"), + Calculation("flat", "big-1"), + Calculation("flat", "big-2") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(11) + + assert(calculations.toList == List( + Calculation("flat", "big-2") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(5) // #Note switch back to small + + assert(calculations.toList == List( + Calculation("flat", "small-2") // Restore current value of small signal + )) + + calculations.clear() + + } + + it("Flattened signals pull check 2") { + + implicit val owner: TestableOwner = new TestableOwner + + val calculations = mutable.Buffer[Calculation[String]]() + + // It's important that we reuse the exact same references to inner signals to check the logic + // - fromSeq streams are used to ensure that onStart isn't called extraneously + // - bus.events streams are used to ensure that onStop isn't called extraneously + + val outerBus = new EventBus[Int].setDisplayName("OuterBus") + + val smallBus = new EventBus[String].setDisplayName("SmallBus") + + val bigBus = new EventBus[String].setDisplayName("BigBus") + + val smallSignal = EventStream.merge( + smallBus.events, + EventStream.fromSeq("small-1" :: "small-2" :: Nil, emitOnce = true).setDisplayName("SmallSeqStream") + ).setDisplayName("SmallMergeStream").startWith("small-0").setDisplayName("SmallSignal") + + val bigSignal = EventStream.merge( + bigBus.events, + EventStream.fromSeq("big-1" :: "big-2" :: Nil, emitOnce = true).setDisplayName("BigSeqStream") + ).setDisplayName("BigMergeStream").startWith("big-0").setDisplayName("BigSignal") + + val flatSignal = outerBus.events.startWith(0).setDisplayName("OuterBus.startWith").flatMap { + case i if i >= 10 => bigSignal + case _ => smallSignal + }.setDisplayName("FlatSignal").map(Calculation.log("flat", calculations)).setDisplayName("FlatSignal--LOG") + + // -- + + flatSignal.addObserver(Observer.empty) + + assert(calculations.toList == List( + Calculation("flat", "small-0"), + Calculation("flat", "small-1"), + Calculation("flat", "small-2"), + )) + + calculations.clear() + + // -- + + smallBus.writer.onNext("small-bus-0") + + assert(calculations.toList == List( + Calculation("flat", "small-bus-0") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(1) + + assert(calculations.toList == List( + Calculation("flat", "small-bus-0") + )) + + calculations.clear() + + // -- + + smallBus.writer.onNext("small-bus-1") + + assert(calculations.toList == List( + Calculation("flat", "small-bus-1") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(2) + + assert(calculations.toList == List( + Calculation("flat", "small-bus-1") + )) + + calculations.clear() + + // -- + + smallBus.writer.onNext("small-bus-2") + + assert(calculations.toList == List( + Calculation("flat", "small-bus-2") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(10) // #Note switch to big + + assert(calculations.toList == List( + Calculation("flat", "big-0"), + Calculation("flat", "big-1"), + Calculation("flat", "big-2") + )) + + calculations.clear() + + // -- + + smallBus.writer.onNext("small bus - unrelated change") + + assert(calculations.isEmpty) + + // -- + + bigBus.writer.onNext("big-bus-1") + + assert(calculations.toList == List( + Calculation("flat", "big-bus-1") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(11) + + assert(calculations.toList == List( + Calculation("flat", "big-bus-1") + )) + + calculations.clear() + + // -- + + bigBus.writer.onNext("big-bus-2") + + assert(calculations.toList == List( + Calculation("flat", "big-bus-2") + )) + + calculations.clear() + + // -- + + outerBus.writer.onNext(5) // #Note switch back to small + + assert(calculations.toList == List( + Calculation("flat", "small-bus-2") // Restore current value of small signal + )) + + calculations.clear() + + // -- + + smallBus.writer.onNext("small-bus-3") + + assert(calculations.toList == List( + Calculation("flat", "small-bus-3") + )) + + calculations.clear() + + // -- + + bigBus.writer.onNext("big bus - unrelated change") + + assert(calculations.isEmpty) + } +} diff --git a/src/test/scala/com/raquo/airstream/core/SignalSpec.scala b/src/test/scala/com/raquo/airstream/core/SignalSpec.scala index 40e675bc..d379cd66 100644 --- a/src/test/scala/com/raquo/airstream/core/SignalSpec.scala +++ b/src/test/scala/com/raquo/airstream/core/SignalSpec.scala @@ -139,10 +139,11 @@ class SignalSpec extends UnitSpec { // -- - // Adding the observer again will work exactly the same as adding it initially signal.addObserver(signalObserver2) - calculations shouldEqual mutable.Buffer() // Using cached calculation + calculations shouldEqual mutable.Buffer( + Calculation("map-signal", 40) + ) effects shouldEqual mutable.Buffer( Effect("signal-obs-2", 40) ) @@ -295,7 +296,7 @@ class SignalSpec extends UnitSpec { effects shouldEqual mutable.Buffer() } - it("MapSignal.now/onNext combination does not redundantly evaluate project/initialValue") { + it("MapSignal.now/onNext re-evaluates project/initialValue when restarting") { implicit val testOwner: TestableOwner = new TestableOwner @@ -333,11 +334,14 @@ class SignalSpec extends UnitSpec { signal.addObserver(signalObserver) - calculations shouldEqual mutable.Buffer() + calculations shouldEqual mutable.Buffer( + Calculation("map-signal", -1) + ) effects shouldEqual mutable.Buffer( Effect("signal-obs", -1) ) + calculations.clear() effects.clear() // -- diff --git a/src/test/scala/com/raquo/airstream/distinct/DistinctSpec.scala b/src/test/scala/com/raquo/airstream/distinct/DistinctSpec.scala index 5eb198cb..cdd0a97d 100644 --- a/src/test/scala/com/raquo/airstream/distinct/DistinctSpec.scala +++ b/src/test/scala/com/raquo/airstream/distinct/DistinctSpec.scala @@ -37,10 +37,10 @@ class DistinctSpec extends UnitSpec { bus.writer.onNext(1) - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("stream", 1) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 1) ) @@ -51,17 +51,17 @@ class DistinctSpec extends UnitSpec { bus.writer.onNext(1) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() // -- bus.writer.onNext(2) - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("stream", 2) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 2) ) @@ -76,17 +76,17 @@ class DistinctSpec extends UnitSpec { bus.writer.onNext(2) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() // -- bus.writer.onNext(3) - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("stream", 3) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 3) ) @@ -95,15 +95,15 @@ class DistinctSpec extends UnitSpec { // -- - errorEffects shouldEqual mutable.Buffer() // nothing failed yet + errorEffects shouldBe mutable.Buffer() // nothing failed yet bus.writer.onError(err1) - calculations shouldEqual mutable.Buffer() + calculations shouldBe mutable.Buffer() - effects shouldEqual mutable.Buffer() + effects shouldBe mutable.Buffer() - errorEffects shouldEqual mutable.Buffer( + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err1") ) @@ -113,7 +113,7 @@ class DistinctSpec extends UnitSpec { bus.writer.onError(err1) - errorEffects shouldEqual mutable.Buffer( + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err1") ) } @@ -142,10 +142,10 @@ class DistinctSpec extends UnitSpec { // -- - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("signal", 0) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 0) ) @@ -156,10 +156,10 @@ class DistinctSpec extends UnitSpec { $var.writer.onNext(1) - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("signal", 1) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 1) ) @@ -170,17 +170,17 @@ class DistinctSpec extends UnitSpec { $var.writer.onNext(1) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() // -- $var.writer.onNext(2) - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("signal", 2) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 2) ) @@ -193,22 +193,30 @@ class DistinctSpec extends UnitSpec { signal.addObserver(obs)(testOwner) - $var.writer.onNext(2) - - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( + Calculation("signal", 2) + ) + effects shouldBe mutable.Buffer( Effect("obs", 2) ) + calculations.clear() effects.clear() + + // -- + + $var.writer.onNext(2) + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() + // -- $var.writer.onNext(3) - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("signal", 3) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 3) ) @@ -217,15 +225,15 @@ class DistinctSpec extends UnitSpec { // -- - errorEffects shouldEqual mutable.Buffer() // nothing failed yet + errorEffects shouldBe mutable.Buffer() // nothing failed yet $var.writer.onError(err1) - calculations shouldEqual mutable.Buffer() + calculations shouldBe mutable.Buffer() - effects shouldEqual mutable.Buffer() + effects shouldBe mutable.Buffer() - errorEffects shouldEqual mutable.Buffer( + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err1") ) @@ -235,7 +243,7 @@ class DistinctSpec extends UnitSpec { $var.writer.onError(err1) - errorEffects shouldEqual mutable.Buffer( + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err1") ) } @@ -268,9 +276,9 @@ class DistinctSpec extends UnitSpec { bus.writer.onError(err1) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() - errorEffects shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err1") ) @@ -280,9 +288,9 @@ class DistinctSpec extends UnitSpec { bus.writer.onError(err2) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() - errorEffects shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err2") ) @@ -292,17 +300,17 @@ class DistinctSpec extends UnitSpec { bus.writer.onError(err2) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() - errorEffects shouldEqual mutable.Buffer() + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() + errorEffects shouldBe mutable.Buffer() // -- bus.writer.onError(err3) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() - errorEffects shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err3") ) @@ -312,13 +320,13 @@ class DistinctSpec extends UnitSpec { bus.writer.onNext(2) - calculations shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer( Calculation("stream", 2) ) - effects shouldEqual mutable.Buffer( + effects shouldBe mutable.Buffer( Effect("obs", 2) ) - errorEffects shouldEqual mutable.Buffer() + errorEffects shouldBe mutable.Buffer() calculations.clear() effects.clear() @@ -327,9 +335,9 @@ class DistinctSpec extends UnitSpec { bus.writer.onError(err3) - calculations shouldEqual mutable.Buffer() - effects shouldEqual mutable.Buffer() - errorEffects shouldEqual mutable.Buffer( + calculations shouldBe mutable.Buffer() + effects shouldBe mutable.Buffer() + errorEffects shouldBe mutable.Buffer( Effect("obs-err", "err3") ) diff --git a/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenFutureSpec.scala b/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenFutureSpec.scala index 5123da15..ceeee6ba 100644 --- a/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenFutureSpec.scala +++ b/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenFutureSpec.scala @@ -38,7 +38,7 @@ class EventStreamFlattenFutureSpec extends AsyncUnitSpec { val promise5 = makePromise() val futureBus = new EventBus[Future[Int]]() - val stream = futureBus.events.flatten(SwitchFutureStrategy) + val stream = futureBus.events.flatMap(EventStream.fromFuture) stream.addObserver(obs) @@ -68,7 +68,7 @@ class EventStreamFlattenFutureSpec extends AsyncUnitSpec { effects shouldEqual mutable.Buffer() }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 400)) + effects shouldEqual mutable.Buffer() // If you expected Effect("obs", 400), use Signal.fromFuture clearLogs() promise3.success(300) @@ -88,148 +88,4 @@ class EventStreamFlattenFutureSpec extends AsyncUnitSpec { } } - it("EventStream.flatten(ConcurrentFutureStrategy)") { - - implicit val owner: TestableOwner = new TestableOwner - - val effects = mutable.Buffer[Effect[Int]]() - - val obs = Observer[Int](effects += Effect("obs", _)) - - def makePromise() = Promise[Int]() - - def clearLogs(): Assertion = { - effects.clear() - assert(true) - } - - val promise1 = makePromise() - val promise2 = makePromise() - val promise3 = makePromise() - val promise4 = makePromise() - val promise5 = makePromise() - - val futureBus = new EventBus[Future[Int]]() - val stream = futureBus.events.flatten(ConcurrentFutureStrategy) - - stream.addObserver(obs) - - futureBus.writer.onNext(promise1.future) - futureBus.writer.onNext(promise2.future) - - delay { - promise2.success(200) - promise1.success(100) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 200), Effect("obs", 100)) - clearLogs() - - promise4.success(400) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - effects shouldEqual mutable.Buffer() - - futureBus.writer.onNext(promise3.future) - futureBus.writer.onNext(promise4.future) // already resolved - futureBus.writer.onNext(promise5.future) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 400)) - clearLogs() - - promise3.success(300) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 300)) - clearLogs() - }.flatMap { _ => - effects shouldEqual mutable.Buffer() - - promise5.success(500) - - effects shouldEqual mutable.Buffer() - }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 500)) - clearLogs() - } - } - - it("EventStream.flatten(OverwriteFutureStrategy)") { - - implicit val owner: TestableOwner = new TestableOwner - - val effects = mutable.Buffer[Effect[Int]]() - - val obs = Observer[Int](effects += Effect("obs", _)) - - def makePromise() = Promise[Int]() - - def clearLogs(): Assertion = { - effects.clear() - assert(true) - } - - val promise1 = makePromise() - val promise2 = makePromise() - val promise3 = makePromise() - val promise4 = makePromise() - val promise5 = makePromise() - - val futureBus = new EventBus[Future[Int]]() - val stream: EventStream[Int] = futureBus.events.flatten(OverwriteFutureStrategy) - - stream.addObserver(obs) - - futureBus.writer.onNext(promise1.future) - futureBus.writer.onNext(promise2.future) - - delay { - promise2.success(200) - promise1.success(100) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 200)) - clearLogs() - - promise4.success(400) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - effects shouldEqual mutable.Buffer() - - futureBus.writer.onNext(promise3.future) - futureBus.writer.onNext(promise4.future) // already resolved - futureBus.writer.onNext(promise5.future) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 400)) - clearLogs() - - promise3.success(300) - - effects shouldEqual mutable.Buffer() - - }.flatMap { _ => - promise5.success(500) - - effects shouldEqual mutable.Buffer() - }.flatMap { _ => - effects shouldEqual mutable.Buffer(Effect("obs", 500)) - clearLogs() - } - } } diff --git a/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenSpec.scala b/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenSpec.scala index 3eafb577..8cc2e1ed 100644 --- a/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenSpec.scala +++ b/src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenSpec.scala @@ -313,11 +313,22 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { // -- + // We don't reset list of streams anymore + mergeStream.addObserver(Observer.empty) bus1.writer.onNext(5) bus2.writer.onNext(30) bus3.writer.onNext(200) - calculations shouldBe mutable.Buffer() + calculations shouldBe mutable.Buffer( + Calculation("stream1", 5), + Calculation("merge", 5), + Calculation("stream2", 30), + Calculation("merge", 30), + Calculation("stream3", 200), + Calculation("merge", 200) + ) + + calculations.clear() // -- @@ -330,6 +341,7 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { Calculation("stream1", 7), Calculation("merge", 7) ) + calculations.clear() done } @@ -347,9 +359,13 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { val stream2 = bus2.events.map(Calculation.log("stream2", calculations)) val stream3 = bus3.events.map(Calculation.log("stream3", calculations)) - val mergeVar = Var[EventStream[Int]](stream1) + val streamVar = Var[EventStream[Int]](stream1) - val mergeSignal = mergeVar.signal.distinct.flatten(ConcurrentStreamStrategy).map(Calculation.log("merge", calculations)) + val mergeSignal = streamVar + .signal + .distinct + .flatten(ConcurrentStreamStrategy) + .map(Calculation.log("merge", calculations)) val sub1 = mergeSignal.addObserver(Observer.empty) @@ -367,7 +383,7 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { // -- - mergeVar.writer.onNext(stream1) + streamVar.writer.onNext(stream1) calculations shouldBe mutable.Buffer() // -- @@ -390,8 +406,8 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { // -- - mergeVar.writer.onNext(stream2) - mergeVar.writer.onNext(stream3) + streamVar.writer.onNext(stream2) + streamVar.writer.onNext(stream3) bus1.writer.onNext(3) bus2.writer.onNext(10) bus3.writer.onNext(100) @@ -422,11 +438,17 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { // -- + // We don't reset the list of streams on stop anymore + mergeSignal.addObserver(Observer.empty) bus1.writer.onNext(5) bus2.writer.onNext(30) bus3.writer.onNext(200) // `stream3` is current value of mergeSignal calculations shouldBe mutable.Buffer( + Calculation("stream1", 5), + Calculation("merge", 5), + Calculation("stream2", 30), + Calculation("merge", 30), Calculation("stream3", 200), Calculation("merge", 200) ) @@ -434,7 +456,7 @@ class EventStreamFlattenSpec extends AsyncUnitSpec { // -- - mergeVar.writer.onNext(stream1) + streamVar.writer.onNext(stream1) // Adding this stream a second time – there is no deduplication, that's why we see duplicate output events bus1.writer.onNext(6) bus1.writer.onNext(7) calculations shouldBe mutable.Buffer( diff --git a/src/test/scala/com/raquo/airstream/flatten/SignalFlattenFutureSpec.scala b/src/test/scala/com/raquo/airstream/flatten/SignalFlattenFutureSpec.scala index bb7d4974..b8bdc303 100644 --- a/src/test/scala/com/raquo/airstream/flatten/SignalFlattenFutureSpec.scala +++ b/src/test/scala/com/raquo/airstream/flatten/SignalFlattenFutureSpec.scala @@ -1,7 +1,7 @@ package com.raquo.airstream.flatten import com.raquo.airstream.AsyncUnitSpec -import com.raquo.airstream.core.Observer +import com.raquo.airstream.core.{Observer, Signal} import com.raquo.airstream.eventbus.EventBus import com.raquo.airstream.fixtures.{Effect, TestableOwner} import org.scalatest.Assertion @@ -11,11 +11,9 @@ import scala.concurrent.{Future, Promise} class SignalFlattenFutureSpec extends AsyncUnitSpec { - // Note: default strategy is SwitchFutureStrategy - describe("Signal.flatten") { - it("initial unresolved future results in an async event") { + it("initial unresolved future results in emitted default value and an async event") { implicit val owner: TestableOwner = new TestableOwner @@ -36,13 +34,16 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { val promise2 = makePromise() val futureBus = new EventBus[Future[Int]]() - val stream = futureBus.events.startWith(promise0.future).flatten + val signal = futureBus.events + .startWith(promise0.future) + .flatMap(Signal.fromFuture(_, initial = -200)) - stream.addObserver(obs) + signal.addObserver(obs) delay { promise0.success(-100) - effects shouldEqual mutable.Buffer() + effects shouldEqual mutable.Buffer(Effect("obs", -200)) + clearLogs() }.flatMap { _ => effects shouldEqual mutable.Buffer(Effect("obs", -100)) @@ -52,17 +53,24 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { futureBus.writer.onNext(promise2.future) promise2.success(200) + promise1.success(100) - effects shouldEqual mutable.Buffer() + // Since this is a Signal, and the futures were emitted prior to being resolved, we get their defined initial values + effects shouldEqual mutable.Buffer( + Effect("obs", -200), + Effect("obs", -200) + ) + clearLogs() }.flatMap { _ => + // Since the signal is only listening to the latest emitted future, we only get 200 here effects shouldEqual mutable.Buffer(Effect("obs", 200)) clearLogs() } } - it("initial future that is resolved at the same time as stream created and observer added result in an async event") { + it("initial future that is resolved sync-before the observer is added results in future's value used as signal's initial value") { implicit val owner: TestableOwner = new TestableOwner @@ -83,16 +91,18 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { val futureBus = new EventBus[Future[Int]]() - val stream = futureBus.events.startWith(promise0.future).flatten + val signal = futureBus.events.startWith(promise0.future).flatMap(Signal.fromFuture(_, initial = -200)) promise0.success(-100) - stream.addObserver(obs) + signal.addObserver(obs) - effects shouldEqual mutable.Buffer() + effects shouldEqual mutable.Buffer( + Effect("obs", -100) + ) + clearLogs() delay { - effects shouldEqual mutable.Buffer(Effect("obs", -100)) - clearLogs() + effects shouldEqual mutable.Buffer() futureBus.writer.onNext(promise1.future) futureBus.writer.onNext(promise2.future) @@ -100,7 +110,12 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { promise2.success(200) promise1.success(100) - effects shouldEqual mutable.Buffer() + // Emitting futures' initial values since they weren't resolved at the time of propagation + effects shouldEqual mutable.Buffer( + Effect("obs", -200), + Effect("obs", -200) + ) + effects.clear() }.flatMap { _ => effects shouldEqual mutable.Buffer(Effect("obs", 200)) @@ -132,20 +147,27 @@ class SignalFlattenFutureSpec extends AsyncUnitSpec { promise0.success(-100) delay { - val stream = futureBus.events.startWith(promise0.future).flatten - stream.addObserver(obs) + val signal = futureBus.events.startWith(promise0.future).flatMap(Signal.fromFuture(_, initial = -200)) + signal.addObserver(obs) - }.flatMap { _ => effects shouldEqual mutable.Buffer(Effect("obs", -100)) clearLogs() + }.flatMap { _ => + effects shouldEqual mutable.Buffer() + futureBus.writer.onNext(promise1.future) futureBus.writer.onNext(promise2.future) promise2.success(200) promise1.success(100) - effects shouldEqual mutable.Buffer() + // Emitting futures' initial values since they weren't resolved at the time of propagation + effects shouldEqual mutable.Buffer( + Effect("obs", -200), + Effect("obs", -200) + ) + clearLogs() }.flatMap { _ => effects shouldEqual mutable.Buffer(Effect("obs", 200)) diff --git a/src/test/scala/com/raquo/airstream/flatten/SwitchEventStreamSpec.scala b/src/test/scala/com/raquo/airstream/flatten/SwitchEventStreamSpec.scala index 37d3d694..91e17871 100644 --- a/src/test/scala/com/raquo/airstream/flatten/SwitchEventStreamSpec.scala +++ b/src/test/scala/com/raquo/airstream/flatten/SwitchEventStreamSpec.scala @@ -161,7 +161,7 @@ class SwitchEventStreamSpec extends UnitSpec { val flattenStream = $latestNumber .map(Calculation.log("flattened", calculations)) - val subFlatten = flattenStream.addObserver(flattenObserver) + val subFlatten1 = flattenStream.addObserver(flattenObserver) calculations shouldEqual mutable.Buffer() effects shouldEqual mutable.Buffer() @@ -209,8 +209,8 @@ class SwitchEventStreamSpec extends UnitSpec { val sourceStream2Observer = Observer[Int](effects += Effect("source-2-obs", _)) - sourceStreams(2).addObserver(sourceStream2Observer) - subFlatten.kill() + val sourceSub2 = sourceStreams(2).addObserver(sourceStream2Observer) + subFlatten1.kill() calculations shouldEqual mutable.Buffer() effects shouldEqual mutable.Buffer() @@ -231,6 +231,37 @@ class SwitchEventStreamSpec extends UnitSpec { // -- + val subFlatten2 = flattenStream.addObserver(flattenObserver) // re-activate flattened stream + + calculations shouldEqual mutable.Buffer() + effects shouldEqual mutable.Buffer() + + // -- re-subscribing to the same stream keeps memory of last stream + + sourceBuses(2).writer.onNext(4) + + calculations shouldEqual mutable.Buffer( + Calculation("source-2", 4), + Calculation("flattened", 4) + ) + effects shouldEqual mutable.Buffer( + Effect("source-2-obs", 4), + Effect("flattened-obs", 4) + ) + + calculations.clear() + effects.clear() + + // -- re-subscribing to a new stream pulls it from parent signal + + subFlatten2.kill() + sourceSub2.kill() + + metaVar.writer.onNext(sourceStreams(3)) + + sourceBuses(1).writer.onNext(5) + sourceBuses(3).writer.onNext(6) + flattenStream.addObserver(flattenObserver) // re-activate flattened stream calculations shouldEqual mutable.Buffer() @@ -238,18 +269,20 @@ class SwitchEventStreamSpec extends UnitSpec { // -- - // flatten stream does not run because it forgot the stream - sourceBuses(2).writer.onNext(4) + sourceBuses(1).writer.onNext(7) + sourceBuses(3).writer.onNext(8) calculations shouldEqual mutable.Buffer( - Calculation("source-2", 4) + Calculation("source-3", 8), + Calculation("flattened", 8) ) effects shouldEqual mutable.Buffer( - Effect("source-2-obs", 4) + Effect("flattened-obs", 8) ) calculations.clear() effects.clear() + } it("EventStream: emitting the same inner stream does not cause it to stop and re-start") { diff --git a/src/test/scala/com/raquo/airstream/flatten/SwitchSignalSpec.scala b/src/test/scala/com/raquo/airstream/flatten/SwitchSignalSpec.scala index e8c217d5..ab81d221 100644 --- a/src/test/scala/com/raquo/airstream/flatten/SwitchSignalSpec.scala +++ b/src/test/scala/com/raquo/airstream/flatten/SwitchSignalSpec.scala @@ -132,15 +132,18 @@ class SwitchSignalSpec extends UnitSpec { // -- - // flattened signal remembers its last tracked signal but wasn't keeping track of state so it emits old state + // flattened signal pulls current value from parent on restart flattenSignal.addObserver(flattenObserver) // re-activate flattened signal - calculations shouldEqual mutable.Buffer() + calculations shouldEqual mutable.Buffer( + Calculation("flattened", 2) + ) effects shouldEqual mutable.Buffer( - Effect("flattened-obs", -1) + Effect("flattened-obs", 2) ) + calculations.clear() effects.clear() // -- diff --git a/src/test/scala/com/raquo/airstream/misc/FoldLeftSignalSpec.scala b/src/test/scala/com/raquo/airstream/misc/FoldLeftSignalSpec.scala index 825db1d3..5472df90 100644 --- a/src/test/scala/com/raquo/airstream/misc/FoldLeftSignalSpec.scala +++ b/src/test/scala/com/raquo/airstream/misc/FoldLeftSignalSpec.scala @@ -4,14 +4,13 @@ import com.raquo.airstream.UnitSpec import com.raquo.airstream.core.Observer import com.raquo.airstream.eventbus.EventBus import com.raquo.airstream.fixtures.{Calculation, Effect, TestableOwner} +import com.raquo.airstream.state.Var import scala.collection.mutable class FoldLeftSignalSpec extends UnitSpec { - // @TODO[Test] Verify Signal.fold and State.fold as well - - it("FoldSignal made with EventStream.fold") { + it("FoldSignal made with EventStream.foldLeft") { implicit val testOwner: TestableOwner = new TestableOwner @@ -65,13 +64,25 @@ class FoldLeftSignalSpec extends UnitSpec { bus.writer.onNext(3) signal.addObserver(signalObserver) + + calculations shouldEqual mutable.Buffer( + Calculation("signal", "numbers: 2") + ) + effects shouldEqual mutable.Buffer( + Effect("signal-obs", "numbers: 2") + ) + + calculations.clear() + effects.clear() + + // -- + bus.writer.onNext(4) calculations shouldEqual mutable.Buffer( Calculation("signal", "numbers: 2 4") ) effects shouldEqual mutable.Buffer( - Effect("signal-obs", "numbers: 2"), // new observer getting initial value Effect("signal-obs", "numbers: 2 4") ) @@ -79,4 +90,105 @@ class FoldLeftSignalSpec extends UnitSpec { effects.clear() } + + it("FoldSignal made with Signal.foldLeft") { + + implicit val testOwner: TestableOwner = new TestableOwner + + val effects = mutable.Buffer[Effect[String]]() + val calculations = mutable.Buffer[Calculation[String]]() + + val signalObserver = Observer[String](effects += Effect("signal-obs", _)) + + val $var = Var(0) + + val signal = $var.signal + .foldLeft(makeInitial = initial => s"numbers: init=${initial}"){ (acc, nextValue) => acc + " " + nextValue.toString } + .map(Calculation.log("signal", calculations)) + + $var.writer.onNext(1) + + calculations shouldEqual mutable.Buffer() + effects shouldEqual mutable.Buffer() + + // -- + + val sub1 = signal.addObserver(signalObserver) + + calculations shouldEqual mutable.Buffer( + Calculation("signal", "numbers: init=1") + ) + effects shouldEqual mutable.Buffer( + Effect("signal-obs", "numbers: init=1") + ) + + calculations.clear() + effects.clear() + + // -- + + $var.writer.onNext(2) + + calculations shouldEqual mutable.Buffer( + Calculation("signal", "numbers: init=1 2") + ) + effects shouldEqual mutable.Buffer( + Effect("signal-obs", "numbers: init=1 2") + ) + + calculations.clear() + effects.clear() + + // -- + + sub1.kill() + + $var.writer.onNext(3) + + val sub2 = signal.addObserver(signalObserver) + + // Re-synced to upstream + calculations shouldEqual mutable.Buffer( + Calculation("signal", "numbers: init=1 2 3") + ) + effects shouldEqual mutable.Buffer( + Effect("signal-obs", "numbers: init=1 2 3") + ) + + calculations.clear() + effects.clear() + + // -- + + $var.writer.onNext(4) + + calculations shouldEqual mutable.Buffer( + Calculation("signal", "numbers: init=1 2 3 4") + ) + effects shouldEqual mutable.Buffer( + Effect("signal-obs", "numbers: init=1 2 3 4") + ) + + calculations.clear() + effects.clear() + + // -- + + sub2.kill() + + $var.writer.onNext(4) + + signal.addObserver(signalObserver) + + // Re-synced to upstream without emitting an extraneous `4` + calculations shouldEqual mutable.Buffer( + Calculation("signal", "numbers: init=1 2 3 4") + ) + effects shouldEqual mutable.Buffer( + Effect("signal-obs", "numbers: init=1 2 3 4") + ) + + calculations.clear() + effects.clear() + } } diff --git a/src/test/scala/com/raquo/airstream/state/VarSpec.scala b/src/test/scala/com/raquo/airstream/state/VarSpec.scala index 4fe2e0df..00006dbe 100644 --- a/src/test/scala/com/raquo/airstream/state/VarSpec.scala +++ b/src/test/scala/com/raquo/airstream/state/VarSpec.scala @@ -119,10 +119,11 @@ class VarSpec extends UnitSpec with BeforeAndAfter { signal.addObserver(obs)(owner) - // Emit a value to the new external observer. Standard Signal behaviour. - assert(calculations == mutable.Buffer()) - assert(effects == mutable.Buffer(Effect("obs", 3))) + // Re-sync the value and emit it to the new external observer. Standard Signal behaviour. + assert(calculations == mutable.Buffer(Calculation("signal", 4))) + assert(effects == mutable.Buffer(Effect("obs", 4))) + calculations.clear() effects.clear() // -- diff --git a/src/test/scala/com/raquo/airstream/syntax/SyntaxSpec.scala b/src/test/scala/com/raquo/airstream/syntax/SyntaxSpec.scala index ab3999b5..a8eeeb50 100644 --- a/src/test/scala/com/raquo/airstream/syntax/SyntaxSpec.scala +++ b/src/test/scala/com/raquo/airstream/syntax/SyntaxSpec.scala @@ -57,18 +57,18 @@ class SyntaxSpec extends UnitSpec { } } - it("SwitchFutureStrategy") { + it("Replacement for ye olde SwitchFutureStrategy") { val bus = new EventBus[Int] locally { - val flatStream = bus.events.flatMap(a => Future.successful(a)) + val flatStream = bus.events.flatMap(a => EventStream.fromFuture(Future.successful(a))) flatStream: EventStream[Int] } locally { - val flatSignal = bus.events.startWith(0).flatMap(a => Future.successful(a)) - flatSignal: EventStream[Int] + val flatSignal = bus.events.startWith(0).flatMap(a => Signal.fromFuture(Future.successful(a), initial = 0)) + flatSignal: Signal[Int] } } diff --git a/src/test/scala/com/raquo/airstream/timing/SignalFromFutureSpec.scala b/src/test/scala/com/raquo/airstream/timing/SignalFromFutureSpec.scala index 03e5e71b..13c254ad 100644 --- a/src/test/scala/com/raquo/airstream/timing/SignalFromFutureSpec.scala +++ b/src/test/scala/com/raquo/airstream/timing/SignalFromFutureSpec.scala @@ -115,30 +115,4 @@ class SignalFromFutureSpec extends AsyncUnitSpec with BeforeAndAfter { } } - it("exposes current value even without observers (unresolved future)") { - val promise = makePromise() - val signal = Signal.fromFuture(promise.future) // Don't use `makeSignal` here, we need the _original_, strict signal - - assert(signal.now().isEmpty) - - promise.success(100) - - // @TODO[API] This is empty because we've triggered signal's initialValue evaluation by the assert above. - // - After that, we can only track Future's updates asynchronously using onComplete. - // - I don't think we want to implement a pull-based system for this. - assert(signal.now().isEmpty) - - delay { - assert(signal.now().contains(100)) - } - } - - it("exposes current value even without observers (resolved future)") { - val promise = makePromise() - promise.success(100) - - val signal = Signal.fromFuture(promise.future) // Don't use `makeSignal` here, we need the _original_, strict signal - - assert(signal.now().contains(100)) - } } diff --git a/src/test/scala/com/somebody/else/ExtensionSpec.scala b/src/test/scala/com/somebody/else/ExtensionSpec.scala index 1e11ec44..61385601 100644 --- a/src/test/scala/com/somebody/else/ExtensionSpec.scala +++ b/src/test/scala/com/somebody/else/ExtensionSpec.scala @@ -1,8 +1,8 @@ package com.somebody.`else` import com.raquo.airstream.UnitSpec -import com.raquo.airstream.common.{InternalTryObserver, SingleParentObservable} -import com.raquo.airstream.core.{Protected, Signal, Transaction, WritableSignal} +import com.raquo.airstream.common.{InternalTryObserver, SingleParentSignal} +import com.raquo.airstream.core.{Protected, Signal, Transaction} import com.somebody.`else`.ExtensionSpec.ExtSignal import scala.util.Try @@ -20,14 +20,14 @@ object ExtensionSpec { class ExtSignal[I, O]( override protected[this] val parent: Signal[I], project: I => O - ) extends WritableSignal[O] with SingleParentObservable[I, O] with InternalTryObserver[I] { + ) extends SingleParentSignal[I, O] with InternalTryObserver[I] { override protected val topoRank: Int = Protected.topoRank(parent) + 1 - override protected def initialValue: Try[O] = Protected.tryNow(parent).map(project) - override protected def onTry(nextParentValue: Try[I], transaction: Transaction): Unit = { fireTry(nextParentValue.map(project), transaction) } + + override protected def currentValueFromParent(): Try[O] = Protected.tryNow(parent).map(project) } }