├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ └── release.yml ├── project ├── build.properties ├── project │ ├── build.properties │ └── ProjectVersions.scala ├── build.sbt ├── plugins.sbt ├── Versions.scala ├── GenerateTupleSignals.scala ├── GenerateTupleStreams.scala ├── SourceGenerator.scala ├── GenerateStaticSignalCombineOps.scala ├── GenerateStaticStreamCombineOps.scala └── GenerateCombineSignalsTest.scala ├── CODEOWNERS ├── src ├── test │ └── scala │ │ └── com │ │ ├── raquo │ │ └── airstream │ │ │ ├── fixtures │ │ │ ├── ExpectedError.scala │ │ │ ├── TestableSubscription.scala │ │ │ ├── TestableOwner.scala │ │ │ ├── Calculation.scala │ │ │ ├── TestableOneTimeOwner.scala │ │ │ └── Effect.scala │ │ │ ├── UnitSpec.scala │ │ │ ├── AsyncUnitSpec.scala │ │ │ ├── Matchers.scala │ │ │ ├── javaflow │ │ │ └── FlowPublisherStreamSpec.scala │ │ │ ├── state │ │ │ └── OwnedSignalSpec.scala │ │ │ ├── combine │ │ │ ├── CombineSeqSignalSpec.scala │ │ │ ├── CombineSeqStreamSpec.scala │ │ │ └── MergeStreamSpec.scala │ │ │ ├── core │ │ │ └── SharedStartStreamSpec.scala │ │ │ ├── timing │ │ │ └── DelayStreamSpec.scala │ │ │ ├── ShouldSyntax.scala │ │ │ ├── status │ │ │ └── StatusSpec.scala │ │ │ ├── flatten │ │ │ └── EventStreamFlattenFutureSpec.scala │ │ │ ├── ownership │ │ │ └── DynamicOwnerSpec.scala │ │ │ └── extensions │ │ │ └── OptionObservableSpec.scala │ │ └── somebody │ │ └── else │ │ └── ExtensionSpec.scala └── main │ ├── scala │ └── com │ │ └── raquo │ │ └── airstream │ │ ├── common │ │ ├── Observation.scala │ │ ├── InternalNextErrorObserver.scala │ │ ├── MultiParentStream.scala │ │ ├── InternalTryObserver.scala │ │ ├── SingleParentStream.scala │ │ ├── MultiParentSignal.scala │ │ ├── InternalParentObserver.scala │ │ └── SingleParentSignal.scala │ │ ├── util │ │ ├── package.scala │ │ └── JsPriorityQueue.scala │ │ ├── web │ │ ├── package.scala │ │ ├── DomEventStream.scala │ │ └── WebStorageBuilder.scala │ │ ├── core │ │ ├── SyncObservable.scala │ │ ├── ObserverList.scala │ │ ├── Sink.scala │ │ ├── Named.scala │ │ ├── Source.scala │ │ ├── WritableStream.scala │ │ ├── Protected.scala │ │ └── InternalObserver.scala │ │ ├── debug │ │ ├── DebuggableStream.scala │ │ ├── Debugger.scala │ │ ├── DebuggerObserver.scala │ │ ├── DebuggerStream.scala │ │ ├── DebuggerSignal.scala │ │ ├── DebuggerObservable.scala │ │ └── DebuggableSignal.scala │ │ ├── state │ │ ├── OwnedSignal.scala │ │ ├── Val.scala │ │ ├── DerivedVarSignal.scala │ │ ├── ObservedSignal.scala │ │ ├── SourceVar.scala │ │ ├── StrictSignal.scala │ │ ├── VarSignal.scala │ │ ├── LazyStrictSignal.scala │ │ ├── LazyDerivedVar.scala │ │ └── LazyDerivedVar2.scala │ │ ├── ownership │ │ ├── ManualOwner.scala │ │ ├── OneTimeOwner.scala │ │ ├── Subscription.scala │ │ └── Owner.scala │ │ ├── extensions │ │ ├── EitherThrowableObservable.scala │ │ ├── BooleanObservable.scala │ │ ├── BooleanStream.scala │ │ ├── BooleanSignal.scala │ │ ├── MetaObservable.scala │ │ ├── EitherObservable.scala │ │ ├── TrySignal.scala │ │ ├── StatusObservable.scala │ │ ├── TryObservable.scala │ │ ├── EitherSignal.scala │ │ ├── OptionSignal.scala │ │ ├── StatusSignal.scala │ │ ├── OptionStream.scala │ │ ├── OptionObservable.scala │ │ ├── TryStream.scala │ │ ├── OptionVar.scala │ │ └── EitherStream.scala │ │ ├── split │ │ ├── SplittableOneSignal.scala │ │ ├── SplittableSignal.scala │ │ ├── SplittableOneStream.scala │ │ ├── SplittableStream.scala │ │ └── DuplicateKeysConfig.scala │ │ ├── custom │ │ ├── CustomStreamSource.scala │ │ └── CustomSignalSource.scala │ │ ├── status │ │ ├── FlatMapStatusObservable.scala │ │ ├── AsyncStatusObservable.scala │ │ └── Status.scala │ │ ├── javaflow │ │ └── FlowPublisherStream.scala │ │ ├── distinct │ │ ├── DistinctStream.scala │ │ └── DistinctSignal.scala │ │ ├── misc │ │ ├── CollectStream.scala │ │ ├── FilterStream.scala │ │ ├── ScanLeftSignal.scala │ │ ├── SignalFromStream.scala │ │ ├── StreamFromSignal.scala │ │ ├── TakeStream.scala │ │ ├── DropStream.scala │ │ └── MapStream.scala │ │ ├── combine │ │ ├── CombineSignalN.scala │ │ ├── CombineStreamN.scala │ │ ├── SampleCombineSignalN.scala │ │ └── SampleCombineStreamN.scala │ │ ├── timing │ │ ├── SyncDelayStream.scala │ │ ├── DelayStream.scala │ │ ├── DebounceStream.scala │ │ ├── JsPromiseStream.scala │ │ ├── JsPromiseSignal.scala │ │ └── PeriodicStream.scala │ │ └── eventbus │ │ └── EventBus.scala │ ├── scala-2.13 │ └── com │ │ └── raquo │ │ └── airstream │ │ └── core │ │ └── ObservableMacroImplicits.scala │ └── scala-3 │ └── com │ └── raquo │ └── airstream │ └── split │ ├── SplitMatchSeqObservable.scala │ ├── SplitMatchSeqValueObservable.scala │ ├── SplitMatchSeqTypeObservable.scala │ ├── SplitMatchOneObservable.scala │ ├── SplitMatchOneValueObservable.scala │ ├── SplitMatchOneTypeObservable.scala │ └── MacrosUtilities.scala ├── .gitignore ├── .scalafmt.conf ├── release.sbt ├── LICENSE.md └── CONTRIBUTING.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: raquo 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.10.7 2 | -------------------------------------------------------------------------------- /project/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.10.3 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/about-codeowners/ 2 | 3 | * @raquo 4 | -------------------------------------------------------------------------------- /project/project/ProjectVersions.scala: -------------------------------------------------------------------------------- 1 | object ProjectVersions { 2 | 3 | val BuildKit: String = "0.1.0" 4 | } 5 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/fixtures/ExpectedError.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.fixtures 2 | 3 | final case class ExpectedError(msg: String) extends Exception(msg) 4 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/UnitSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream 2 | 3 | import org.scalatest.funspec.AnyFunSpec 4 | 5 | class UnitSpec extends AnyFunSpec with Matchers 6 | -------------------------------------------------------------------------------- /project/build.sbt: -------------------------------------------------------------------------------- 1 | // #Note this is /project/build.sbt – see /build.sbt for the main build config. 2 | 3 | libraryDependencies ++= Seq( 4 | "com.raquo" %% "buildkit" % ProjectVersions.BuildKit, 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/Observation.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.Observable 4 | 5 | import scala.util.Try 6 | 7 | class Observation[A](val observable: Observable[A], val value: Try[A]) 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | *.code-workspace 4 | 5 | .DS_Store 6 | 7 | target 8 | 9 | .metals 10 | metals.sbt 11 | .bloop 12 | .bsp 13 | 14 | project/metals.sbt 15 | project/.bloop 16 | 17 | yarn.lock 18 | !website/yarn.lock 19 | 20 | .downloads 21 | -------------------------------------------------------------------------------- /src/main/scala-2.13/com/raquo/airstream/core/ObservableMacroImplicits.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | trait ObservableMacroImplicits { 4 | // Airstream's macro features are not implemented for Scala 2. 5 | // See same-named file in `scala-3` directory. 6 | } 7 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/util/package.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream 2 | 3 | package object util { 4 | 5 | val always: Any => Boolean = _ => true 6 | 7 | def hasDuplicateTupleKeys(tuples: Seq[(_, _)]): Boolean = { 8 | tuples.size != tuples.map(_._1).toSet.size 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/fixtures/TestableSubscription.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.fixtures 2 | 3 | import com.raquo.airstream.ownership.{Owner, Subscription} 4 | 5 | class TestableSubscription(owner: Owner) { 6 | 7 | var killCount = 0 8 | 9 | val subscription = new Subscription(owner, cleanup = () => { 10 | killCount += 1 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/web/package.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream 2 | 3 | package object web { 4 | 5 | @deprecated("AjaxEventStream renamed to AjaxStream", "15.0.0-M1") 6 | type AjaxEventStream = AjaxStream 7 | 8 | @deprecated("AjaxEventStream renamed to AjaxStream", "15.0.0-M1") 9 | lazy val AjaxEventStream: AjaxStream.type = AjaxStream 10 | } 11 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/fixtures/TestableOwner.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.fixtures 2 | 3 | import com.raquo.airstream.ownership.{Owner, Subscription} 4 | 5 | class TestableOwner extends Owner { 6 | 7 | def _testSubscriptions: List[Subscription] = subscriptions.asScalaJs.toList 8 | 9 | override def killSubscriptions(): Unit = { 10 | super.killSubscriptions() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") 2 | 3 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1") 4 | 5 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.11") 6 | 7 | addSbtPlugin("com.github.sbt" % "sbt-git" % "2.0.1") 8 | 9 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") 10 | 11 | addSbtPlugin("com.typesafe" % "sbt-mima-plugin" % "1.1.4") 12 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/SyncObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | /** Observable that can become pending for the purpose of synchronization - see Transaction for pending logic */ 4 | trait SyncObservable[+A] extends Observable[A] { 5 | 6 | /** This method is called after this pending observable has been resolved */ 7 | private[airstream] def syncFire(transaction: Transaction): Unit 8 | } 9 | -------------------------------------------------------------------------------- /project/Versions.scala: -------------------------------------------------------------------------------- 1 | object Versions { 2 | 3 | val Scala_2_13 = "2.13.16" 4 | 5 | val Scala_3 = "3.3.3" 6 | 7 | // -- Dependencies -- 8 | 9 | val ScalaJsDom = "2.8.0" 10 | 11 | val Tuplez = "0.4.0" 12 | 13 | val Ew = "0.2.0" 14 | 15 | // -- Test -- 16 | 17 | val ScalaTest = "3.2.14" 18 | 19 | val JsDom = "25.0.1" 20 | 21 | val Webpack = "5.96.1" 22 | 23 | val WebpackDevServer = "5.1.0" 24 | 25 | } 26 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | include ".downloads/.scalafmt.shared.conf" 2 | 3 | runner.dialect = "scala213" 4 | 5 | project.excludePaths = [ 6 | "glob:**/project/VersionHelper.scala", 7 | "glob:**/src/*/scala-3/**" #TODO[Build] specify folder-specific rules (pretty much just runner.dialect) 8 | "glob:**/src/*/scala/com/raquo/airstream/combine/generated/**" 9 | "glob:**/src/*/scala/com/raquo/airstream/extensions/{TupleSignals.scala,TupleStreams.scala}" 10 | ] 11 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/debug/DebuggableStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.debug 2 | 3 | import com.raquo.airstream.core.EventStream 4 | 5 | /** This class exists for type inference purposes only (see "observable debugger type inference" test in DebugSpec), 6 | * the real meat is in [[DebuggableObservable]]. 7 | */ 8 | class DebuggableStream[+A](override val observable: EventStream[A]) extends DebuggableObservable[EventStream, A](observable) 9 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/fixtures/Calculation.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.fixtures 2 | 3 | import scala.collection.mutable 4 | 5 | case class Calculation[V](name: String, value: V) 6 | 7 | object Calculation { 8 | 9 | def log[V](name: String, to: mutable.Buffer[Calculation[V]])(value: V): V = { 10 | val calculation = Calculation(name, value) 11 | // println(calculation) 12 | to += calculation 13 | value 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/fixtures/TestableOneTimeOwner.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.fixtures 2 | 3 | import com.raquo.airstream.ownership.{OneTimeOwner, Subscription} 4 | 5 | class TestableOneTimeOwner(onAccessAfterKilled: () => Unit) extends OneTimeOwner(onAccessAfterKilled) { 6 | 7 | def _testSubscriptions: List[Subscription] = subscriptions.asScalaJs.toList 8 | 9 | override def killSubscriptions(): Unit = super.killSubscriptions() 10 | } 11 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/OwnedSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.ownership.Subscription 4 | 5 | // @TODO[API] Should we expose `killOriginalSubscription` to end users? 6 | trait OwnedSignal[+A] extends StrictSignal[A] { 7 | 8 | protected[this] val subscription: Subscription 9 | 10 | /** This only kills the subscription, but this signal might also have other listeners */ 11 | def killOriginalSubscription(): Unit = subscription.kill() 12 | } 13 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/ownership/ManualOwner.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.ownership 2 | 3 | /** All this class does is expose the method to kill subscriptions. 4 | * Just a small convenience for ad-hoc integrations 5 | * 6 | * Please note that this is NOT a [[OneTimeOwner]]. 7 | * If that's what you need, copy this code but extend [[OneTimeOwner]] instead of [[Owner]]. 8 | */ 9 | class ManualOwner extends Owner { 10 | 11 | override def killSubscriptions(): Unit = { 12 | super.killSubscriptions() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/fixtures/Effect.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.fixtures 2 | 3 | import com.raquo.airstream.core.Observer 4 | 5 | import scala.collection.mutable 6 | 7 | case class Effect[V](name: String, value: V) 8 | 9 | object Effect { 10 | 11 | def log[V](name: String, to: mutable.Buffer[Effect[V]])(value: V): V = { 12 | val eff = Effect(name, value) 13 | to += eff 14 | value 15 | } 16 | 17 | def logObserver[V](name: String, to: mutable.Buffer[Effect[V]]): Observer[V] = { 18 | Observer[V](log(name, to)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/InternalNextErrorObserver.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.{InternalObserver, Transaction} 4 | 5 | import scala.util.Try 6 | 7 | /** Observer that requires you to define `onNext` and `onError` */ 8 | trait InternalNextErrorObserver[A] extends InternalObserver[A] { 9 | 10 | final override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 11 | nextValue.fold( 12 | onError(_, transaction), 13 | onNext(_, transaction) 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/MultiParentStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.{Observable, Protected, WritableStream} 4 | import com.raquo.ew.JsArray 5 | 6 | /** A simple stream that has multiple parents. */ 7 | trait MultiParentStream[I, O] extends WritableStream[O] { 8 | 9 | /** This array is read-only, never update it. */ 10 | protected[this] val parents: JsArray[Observable[I]] 11 | 12 | override protected def onWillStart(): Unit = { 13 | parents.forEach(Protected.maybeWillStart(_)) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/EitherThrowableObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{BaseObservable, Observable} 4 | 5 | /** See also: [[EitherStream]] */ 6 | class EitherThrowableObservable[A, B, Self[+_] <: Observable[_]](val observable: BaseObservable[Self, Either[Throwable, B]]) extends AnyVal { 7 | 8 | def recoverLeft[BB >: B](f: Throwable => BB): Self[BB] = { 9 | observable.map(_.fold(f, identity)) 10 | } 11 | 12 | def throwLeft: Self[B] = { 13 | observable.map(_.fold(throw _, identity)) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/InternalTryObserver.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.{InternalObserver, Transaction} 4 | 5 | import scala.util.{Failure, Success} 6 | 7 | trait InternalTryObserver[-A] extends InternalObserver[A] { 8 | 9 | final override protected def onNext(nextValue: A, transaction: Transaction): Unit = { 10 | onTry(Success(nextValue), transaction) 11 | } 12 | 13 | final override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { 14 | onTry(Failure(nextError), transaction) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/BooleanObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{BaseObservable, Observable} 4 | 5 | class BooleanObservable[Self[+_] <: Observable[_]](val observable: BaseObservable[Self, Boolean]) extends AnyVal { 6 | 7 | def invert: Self[Boolean] = { 8 | observable.map(!_) 9 | } 10 | 11 | @inline def not: Self[Boolean] = invert 12 | 13 | def foldBoolean[A]( 14 | whenTrue: => A, 15 | whenFalse: => A 16 | ): Self[A] = { 17 | observable map { 18 | if (_) whenTrue else whenFalse 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ '**' ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | env: 10 | CI: true 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "22" 22 | - name: Setup JVM 23 | uses: actions/setup-java@v3 24 | with: 25 | java-version: '17' 26 | distribution: 'adopt' 27 | - name: Setup SBT 28 | uses: sbt/setup-sbt@v1 29 | - name: Run tests 30 | run: sbt +test 31 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/AsyncUnitSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream 2 | 3 | import org.scalatest.funspec.AsyncFunSpec 4 | 5 | import scala.concurrent.{ExecutionContext, Future, Promise} 6 | import scala.scalajs.js 7 | import scala.util.Try 8 | 9 | abstract class AsyncUnitSpec extends AsyncFunSpec with Matchers { 10 | 11 | override implicit def executionContext: ExecutionContext = scala.scalajs.concurrent.JSExecutionContext.Implicits.queue 12 | 13 | def delay[V](value: => V): Future[V] = { 14 | delay(0)(value) 15 | } 16 | 17 | def delay[V](millis: Int)(value: => V): Future[V] = { 18 | val promise = Promise[V]() 19 | js.timers.setTimeout(millis.toDouble) { 20 | promise.complete(Try(value)) 21 | } 22 | promise.future.map(identity)(executionContext) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /release.sbt: -------------------------------------------------------------------------------- 1 | name := "Airstream" 2 | 3 | normalizedName := "airstream" 4 | 5 | organization := "com.raquo" 6 | 7 | homepage := Some(url("https://github.com/raquo/Airstream")) 8 | 9 | licenses += ("MIT", url("https://github.com/raquo/Airstream/blob/master/LICENSE.md")) 10 | 11 | scmInfo := Some( 12 | ScmInfo( 13 | url("https://github.com/raquo/Airstream"), 14 | "scm:git@github.com/raquo/Airstream.git" 15 | ) 16 | ) 17 | 18 | developers := List( 19 | Developer( 20 | id = "raquo", 21 | name = "Nikita Gazarov", 22 | email = "nikita@raquo.com", 23 | url = url("https://github.com/raquo") 24 | ) 25 | ) 26 | 27 | (Test / publishArtifact) := false 28 | 29 | pomIncludeRepository := { _ => false } 30 | 31 | sonatypeCredentialHost := "s01.oss.sonatype.org" 32 | 33 | sonatypeRepository := "https://s01.oss.sonatype.org/service/local" 34 | 35 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/Val.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.WritableSignal 4 | 5 | import scala.util.{Success, Try} 6 | 7 | class Val[A](constantValue: Try[A]) extends WritableSignal[A] with StrictSignal[A] { 8 | 9 | override protected val topoRank: Int = 1 10 | 11 | /** Value never changes, so we can use a simplified implementation */ 12 | override def tryNow(): Try[A] = constantValue 13 | 14 | override protected def currentValueFromParent(): Try[A] = constantValue 15 | 16 | override protected def onWillStart(): Unit = () // noop 17 | } 18 | 19 | object Val { 20 | 21 | def apply[A](value: A): Val[A] = fromTry(Success(value)) 22 | 23 | @inline def fromTry[A](value: Try[A]): Val[A] = new Val(value) 24 | 25 | @inline def fromEither[A](value: Either[Throwable, A]): Val[A] = new Val(value.toTry) 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/DerivedVarSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.Observer 4 | import com.raquo.airstream.misc.MapSignal 5 | import com.raquo.airstream.ownership.{Owner, Subscription} 6 | 7 | class DerivedVarSignal[A, B]( 8 | parent: Var[A], 9 | zoomIn: A => B, 10 | owner: Owner, 11 | parentDisplayName: => String 12 | ) extends MapSignal[A, B]( 13 | parent.signal, 14 | project = zoomIn, 15 | recover = None 16 | ) with OwnedSignal[B] { 17 | 18 | // Note that even if owner kills subscription, this signal might remain due to other listeners 19 | override protected[state] def isStarted: Boolean = super.isStarted 20 | 21 | override protected[this] val subscription: Subscription = this.addObserver(Observer.empty)(owner) 22 | 23 | override protected def defaultDisplayName: String = parentDisplayName + ".signal" 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/SingleParentStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.{InternalObserver, Observable, Protected, WritableStream} 4 | 5 | /** A simple stream that only has one parent. */ 6 | trait SingleParentStream[I, O] extends WritableStream[O] with InternalObserver[I] { 7 | 8 | protected[this] val parent: Observable[I] 9 | 10 | override protected def onWillStart(): Unit = { 11 | // println(s"${this} > onWillStart") 12 | Protected.maybeWillStart(parent) 13 | } 14 | 15 | override protected[this] def onStart(): Unit = { 16 | // println(s"${this} > onStart") 17 | parent.addInternalObserver(this, shouldCallMaybeWillStart = false) 18 | super.onStart() 19 | } 20 | 21 | override protected[this] def onStop(): Unit = { 22 | parent.removeInternalObserver(observer = this) 23 | super.onStop() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [publish] 6 | tags: ["*"] 7 | 8 | env: 9 | CI: true 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-22.04 14 | strategy: 15 | fail-fast: true 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Setup JVM 20 | uses: actions/setup-java@v3 21 | with: 22 | java-version: '17' 23 | distribution: 'adopt' 24 | - name: Setup SBT 25 | uses: sbt/setup-sbt@v1 26 | - name: Tests 27 | run: sbt +test 28 | - name: Release 29 | run: sbt ci-release 30 | env: 31 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 32 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 33 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 34 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 35 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/ObservedSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.{Observer, Signal} 4 | import com.raquo.airstream.misc.MapSignal 5 | import com.raquo.airstream.ownership.{Owner, Subscription} 6 | 7 | /** This class adds a noop observer to `signal`, ensuring that its current value is computed. 8 | * It then lets you query `signal`'s current value with `now` and `tryNow` methods (see StrictSignal), 9 | * as well as kill the subscription (see OwnedSignal) 10 | */ 11 | class ObservedSignal[A]( 12 | override val parent: Signal[A], 13 | observer: Observer[A], 14 | owner: Owner 15 | ) extends MapSignal[A, A]( 16 | parent, 17 | project = identity, 18 | recover = None 19 | ) with OwnedSignal[A] { 20 | 21 | override protected[this] val subscription: Subscription = addObserver(observer)(owner) 22 | 23 | override protected def defaultDisplayName: String = parent.displayName + s".observe@${hashCode()}" 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/split/SplittableOneSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.Signal 4 | 5 | class SplittableOneSignal[Input](val signal: Signal[Input]) extends AnyVal { 6 | 7 | /** This operator works like `split`, but with only one item, not a collection of items. */ 8 | def splitOne[Output, Key]( 9 | key: Input => Key 10 | )( 11 | project: (Key, Input, Signal[Input]) => Output 12 | ): Signal[Output] = { 13 | // @TODO[Performance] Would be great if we didn't need two .map-s, but I can't figure out how to do that 14 | // Note: We never have duplicate keys here, so we can use 15 | // DuplicateKeysConfig.noWarnings to improve performance 16 | new SplittableSignal[Option, Input](signal.map(Some(_))) 17 | .split( 18 | key, 19 | distinctCompose = identity, 20 | DuplicateKeysConfig.noWarnings 21 | )( 22 | project 23 | ) 24 | .map(_.get) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/Matchers.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream 2 | 3 | import org.scalactic.{source, Prettifier} 4 | import org.scalatest.{Assertion, Assertions} 5 | import org.scalatest.matchers.should 6 | 7 | trait Matchers { this: Assertions => 8 | 9 | val raw: should.Matchers = new should.Matchers {} 10 | 11 | def assertEquals( 12 | actual: scala.Any, 13 | expected: scala.Any 14 | )(implicit 15 | prettifier: org.scalactic.Prettifier, 16 | pos: org.scalactic.source.Position 17 | ): Assertion = { 18 | assertResult(expected = expected)(actual = actual) 19 | } 20 | 21 | def assertEquals( 22 | actual: scala.Any, 23 | expected: scala.Any, 24 | clue: scala.Any 25 | )(implicit 26 | prettifier: Prettifier, 27 | pos: source.Position 28 | ): Assertion = { 29 | assertResult(expected = expected, clue = clue)(actual = actual) 30 | } 31 | 32 | implicit def withShouldSyntax[A](value: A): ShouldSyntax[A] = new ShouldSyntax[A](value) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/ownership/OneTimeOwner.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.ownership 2 | 3 | /** Owner that can not be used after it was killed. Used in Laminar via [[DynamicOwner]]. 4 | * 5 | * @param onAccessAfterKilled 6 | * Called if you attempt to use this owner after it was killed. 7 | * It's intended to log and/or throw for reporting / debugging purposes. 8 | */ 9 | class OneTimeOwner(onAccessAfterKilled: () => Unit) extends Owner { 10 | 11 | private var _isKilledForever: Boolean = false 12 | 13 | @inline def isKilledForever: Boolean = _isKilledForever 14 | 15 | override private[ownership] def own(subscription: Subscription): Unit = { 16 | if (_isKilledForever) { 17 | subscription.onKilledByOwner() 18 | onAccessAfterKilled() 19 | } else { 20 | super.own(subscription) 21 | } 22 | } 23 | 24 | override protected[this] def killSubscriptions(): Unit = { 25 | super.killSubscriptions() 26 | _isKilledForever = true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/airstream/split/SplitMatchSeqObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{Observable, BaseObservable} 4 | import scala.annotation.compileTimeOnly 5 | import com.raquo.airstream.core.Signal 6 | import com.raquo.airstream.split.MacrosUtilities.{CaseAny, HandlerAny} 7 | 8 | final case class SplitMatchSeqObservable[Self[+_] <: Observable[_] , I, K, O, CC[_]] private (private val underlying: Unit) extends AnyVal 9 | 10 | object SplitMatchSeqObservable { 11 | 12 | @compileTimeOnly("`splitMatchSeq` without `toSignal` is illegal") 13 | def build[Self[+_] <: Observable[_] , I, K, O, CC[_]]( 14 | keyFn: Function1[I, K], 15 | distinctCompose: Function1[Signal[I], Signal[I]], 16 | duplicateKeysConfig: DuplicateKeysConfig, 17 | observable: BaseObservable[Self, CC[I]] 18 | )( 19 | caseList: CaseAny* 20 | )( 21 | handleList: HandlerAny[O]* 22 | ): SplitMatchSeqObservable[Self, I, K, O, CC] = throw new UnsupportedOperationException("`splitMatchSeq` without `toSignal` is illegal") 23 | 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright 2018 Nikita Gazarov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/custom/CustomStreamSource.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.custom 2 | 3 | import com.raquo.airstream.core.{EventStream, Transaction, WritableStream} 4 | import com.raquo.airstream.custom.CustomSource._ 5 | 6 | /** Use this to easily create a custom signal from an external source 7 | * 8 | * See docs on custom sources, and [[CustomSource.Config]] 9 | */ 10 | class CustomStreamSource[A]( 11 | makeConfig: (FireValue[A], FireError, GetStartIndex, GetIsStarted) => CustomSource.Config, 12 | ) extends WritableStream[A] with CustomSource[A] { 13 | 14 | override protected[this] val config: Config = makeConfig( 15 | value => Transaction(fireValue(value, _)), 16 | err => Transaction(fireError(err, _)), 17 | () => startIndex, 18 | () => isStarted 19 | ) 20 | } 21 | 22 | object CustomStreamSource { 23 | 24 | @deprecated("Use EventStream.fromCustomSource", "15.0.0-M1") 25 | def apply[A]( 26 | config: (FireValue[A], FireError, GetStartIndex, GetIsStarted) => Config 27 | ): EventStream[A] = { 28 | new CustomStreamSource[A](config) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/ObserverList.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | import com.raquo.ew.JsArray 4 | 5 | class ObserverList[Obs](private val observers: JsArray[Obs]) extends AnyVal { 6 | 7 | @inline def length: Int = observers.length 8 | 9 | @inline def apply(index: Int): Obs = observers(index) 10 | 11 | @inline def push(observer: Obs): Unit = observers.push(observer) 12 | 13 | /** @return whether observer was removed (`false` if it wasn't in the list) */ 14 | def removeObserverNow(observer: Obs): Boolean = { 15 | val index = observers.indexOf(observer) 16 | val shouldRemove = index != -1 17 | if (shouldRemove) { 18 | observers.splice(index, deleteCount = 1) 19 | } 20 | shouldRemove 21 | } 22 | 23 | /** @param fn Must not throw */ 24 | def foreach(fn: Obs => Unit): Unit = { 25 | var index = 0 26 | while (index < observers.length) { 27 | val observer = observers(index) 28 | index += 1 // Do this before invoking `fn` for a more graceful failure in case `fn` throws 29 | fn(observer) 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/BooleanStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{EventStream, Signal} 4 | import com.raquo.airstream.split.SplittableOneStream 5 | 6 | class BooleanStream(val stream: EventStream[Boolean]) extends AnyVal { 7 | 8 | /** 9 | * Split a stream of booleans. 10 | * 11 | * @param trueF called when the parent stream switches from `false` to `true`. 12 | * The provided signal emits `Unit` on every `true` event emitted by the stream. 13 | * @param falseF called when the parent stream switches from `true` to `false`. 14 | * The provided signal emits `Unit` on every `false` event emitted by the stream. 15 | */ 16 | def splitBoolean[C]( 17 | trueF: Signal[Unit] => C, 18 | falseF: Signal[Unit] => C 19 | ): EventStream[C] = { 20 | new SplittableOneStream(stream).splitOne(identity) { 21 | (_, initial, signal) => 22 | if (initial) 23 | trueF(signal.mapToUnit) 24 | else 25 | falseF(signal.mapToUnit) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/BooleanSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.Signal 4 | import com.raquo.airstream.split.SplittableOneSignal 5 | 6 | class BooleanSignal(val signal: Signal[Boolean]) extends AnyVal { 7 | 8 | /** 9 | * Split a signal of booleans. 10 | * 11 | * @param whenTrue called when the parent signal switches from `false` to `true`. 12 | * The provided signal emits `Unit` on every `true` event from the parent signal. 13 | * @param whenFalse called when the parent signal switches from `true` to `false`. 14 | * The provided signal emits `Unit` on every `false` event from the parent signal. 15 | */ 16 | def splitBoolean[C]( 17 | whenTrue: Signal[Unit] => C, 18 | whenFalse: Signal[Unit] => C 19 | ): Signal[C] = { 20 | new SplittableOneSignal(signal).splitOne(identity) { 21 | (_, initial, signal) => 22 | if (initial) 23 | whenTrue(signal.mapToUnit) 24 | else 25 | whenFalse(signal.mapToUnit) 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/status/FlatMapStatusObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.status 2 | 3 | import com.raquo.airstream.core.{BaseObservable, EventStream, Observable} 4 | 5 | object FlatMapStatusObservable { 6 | 7 | def apply[A, B, Self[+_] <: Observable[_]]( 8 | parent: BaseObservable[Self, A], 9 | project: A => EventStream[B] 10 | ): Self[Status[A, B]] = { 11 | // #TODO[Integrity] Not sure how to type this properly 12 | val parentObservable = parent.asInstanceOf[Observable[A] with BaseObservable[Self, A]] 13 | 14 | var ix = 0 15 | 16 | val inputS = parentObservable.map { input => 17 | ix = 0 18 | Pending[A](input) 19 | } 20 | 21 | val outputS = inputS.flatMapSwitch { pending => 22 | project(pending.input).map { output => 23 | ix += 1 24 | Resolved(pending.input, output, ix) 25 | } 26 | } 27 | 28 | inputS.matchStreamOrSignal( 29 | ifStream = _.mergeWith(outputS), 30 | ifSignal = _.changes(_.mergeWith(outputS)) 31 | ).asInstanceOf[Self[Status[A, B]]] // #TODO[Integrity] How to type this properly? 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/airstream/split/SplitMatchSeqValueObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{Observable, BaseObservable, Signal} 4 | import scala.annotation.compileTimeOnly 5 | import com.raquo.airstream.split.MacrosUtilities.{CaseAny, HandlerAny, MatchValueHandler} 6 | 7 | final case class SplitMatchSeqValueObservable[Self[+_] <: Observable[_] , I, K, O, CC[_], V] private (private val underlying: Unit) extends AnyVal 8 | 9 | object SplitMatchSeqValueObservable { 10 | 11 | @compileTimeOnly("`splitMatchSeq` without `toSignal` is illegal") 12 | def build[Self[+_] <: Observable[_] , I, K, O, CC[_], V]( 13 | keyFn: Function1[I, K], 14 | distinctCompose: Function1[Signal[I], Signal[I]], 15 | duplicateKeysConfig: DuplicateKeysConfig, 16 | observable: BaseObservable[Self, CC[I]] 17 | )( 18 | caseList: CaseAny* 19 | )( 20 | handleList: HandlerAny[O]* 21 | )( 22 | valueHandler: MatchValueHandler[V] 23 | ): SplitMatchSeqValueObservable[Self, I, K, O, CC, V] = throw new UnsupportedOperationException("`splitMatchSeq` without `toSignal` is illegal") 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/SourceVar.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.Transaction 4 | 5 | import scala.util.Try 6 | 7 | /** The regular Var that's created with `Var.apply`. 8 | * 9 | * See also DerivedVar, created with `myVar.zoom(a => b)((a, b) => a)(owner)`, 10 | * and LazyDerivedVar, created with `myVar.zoomLazy(a => b)((a, b) => a)` 11 | */ 12 | class SourceVar[A](initial: Try[A]) extends Var[A] { 13 | 14 | private[this] var currentValue: Try[A] = initial 15 | 16 | /** VarSignal is a private type, do not expose it */ 17 | private[this] val _varSignal = new VarSignal[A]( 18 | initial = currentValue, 19 | parentDisplayName = displayName 20 | ) 21 | 22 | override private[state] def underlyingVar: SourceVar[_] = this 23 | 24 | override private[state] def getCurrentValue: Try[A] = currentValue 25 | 26 | override private[state] def setCurrentValue( 27 | value: Try[A], 28 | transaction: Transaction 29 | ): Unit = { 30 | currentValue = value 31 | _varSignal.onTry(value, transaction) 32 | } 33 | 34 | override val signal: StrictSignal[A] = _varSignal 35 | } 36 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/airstream/split/SplitMatchSeqTypeObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{Observable, BaseObservable} 4 | import scala.annotation.compileTimeOnly 5 | import com.raquo.airstream.core.Signal 6 | import com.raquo.airstream.split.MacrosUtilities.{CaseAny, HandlerAny, MatchTypeHandler} 7 | 8 | final case class SplitMatchSeqTypeObservable[Self[+_] <: Observable[_] , I, K, O, CC[_], T] private (private val underlying: Unit) extends AnyVal 9 | 10 | object SplitMatchSeqTypeObservable { 11 | 12 | @compileTimeOnly("`splitMatchSeq` without `toSignal` is illegal") 13 | def build[Self[+_] <: Observable[_] , I, K, O, CC[_], T]( 14 | keyFn: Function1[I, K], 15 | distinctCompose: Function1[Signal[I], Signal[I]], 16 | duplicateKeysConfig: DuplicateKeysConfig, 17 | observable: BaseObservable[Self, CC[I]] 18 | )( 19 | caseList: CaseAny* 20 | )( 21 | handleList: HandlerAny[O]* 22 | )( 23 | tHandler: MatchTypeHandler[T] 24 | ): SplitMatchSeqTypeObservable[Self, I, K, O, CC, T] = throw new UnsupportedOperationException("`splitMatchSeq` without `toSignal` is illegal") 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/MetaObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.Observable 4 | import com.raquo.airstream.flatten._ 5 | 6 | import scala.annotation.unused 7 | 8 | class MetaObservable[A, Outer[+_] <: Observable[_], Inner[_]]( 9 | val parent: Outer[Inner[A]] 10 | ) extends AnyVal { 11 | 12 | @inline def flatten[Output[+_] <: Observable[_]]( 13 | implicit strategy: SwitchingStrategy[Outer, Inner, Output], 14 | @unused allowFlatMap: AllowFlatten 15 | ): Output[A] = { 16 | strategy.flatten(parent) 17 | } 18 | 19 | @inline def flattenSwitch[Output[+_] <: Observable[_]]( 20 | implicit strategy: SwitchingStrategy[Outer, Inner, Output] 21 | ): Output[A] = { 22 | strategy.flatten(parent) 23 | } 24 | 25 | @inline def flattenMerge[Output[+_] <: Observable[_]]( 26 | implicit strategy: MergingStrategy[Outer, Inner, Output] 27 | ): Output[A] = { 28 | strategy.flatten(parent) 29 | } 30 | 31 | @inline def flattenCustom[Output[+_] <: Observable[_]]( 32 | strategy: FlattenStrategy[Outer, Inner, Output] 33 | ): Output[A] = { 34 | strategy.flatten(parent) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/test/scala/com/somebody/else/ExtensionSpec.scala: -------------------------------------------------------------------------------- 1 | package com.somebody.`else` 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.common.SingleParentSignal 5 | import com.raquo.airstream.core.{Protected, Signal, Transaction} 6 | import com.somebody.`else`.ExtensionSpec.ExtSignal 7 | 8 | import scala.util.Try 9 | 10 | class ExtensionSpec extends UnitSpec { 11 | 12 | it("Allow extension of observables including overriding and accessing all required methods") { 13 | val parent = Signal.fromValue(0) 14 | new ExtSignal[Int, String](parent, _.toString) 15 | } 16 | } 17 | 18 | object ExtensionSpec { 19 | 20 | class ExtSignal[I, O]( 21 | override protected[this] val parent: Signal[I], 22 | project: I => O 23 | ) extends SingleParentSignal[I, O] { 24 | 25 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 26 | 27 | override protected def onTry(nextParentValue: Try[I], transaction: Transaction): Unit = { 28 | super.onTry(nextParentValue, transaction) 29 | fireTry(nextParentValue.map(project), transaction) 30 | } 31 | 32 | override protected def currentValueFromParent(): Try[O] = Protected.tryNow(parent).map(project) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/javaflow/FlowPublisherStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.javaflow 2 | 3 | import com.raquo.airstream.core.EventStream 4 | 5 | import java.util.concurrent.Flow 6 | 7 | object FlowPublisherStream { 8 | 9 | def apply[A](publisher: Flow.Publisher[A], emitOnce: Boolean = false): EventStream[A] = { 10 | var subscription: Flow.Subscription = null 11 | 12 | EventStream.fromCustomSource[A]( 13 | shouldStart = startIndex => if (emitOnce) startIndex == 1 else true, 14 | start = (fireEvent, fireError, _, _) => { 15 | val subscriber = new Flow.Subscriber[A] { 16 | def onNext(value: A): Unit = fireEvent(value) 17 | def onError(err: Throwable): Unit = fireError(err) 18 | def onComplete(): Unit = () 19 | def onSubscribe(sub: Flow.Subscription): Unit = { 20 | sub.request(Long.MaxValue) // unlimited demand for events 21 | subscription = sub 22 | } 23 | } 24 | publisher.subscribe(subscriber) 25 | }, 26 | stop = _ => { 27 | if (subscription ne null) { 28 | subscription.cancel() 29 | subscription = null 30 | } 31 | } 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/StrictSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.Signal 4 | 5 | import scala.util.Try 6 | 7 | /** A Signal that lets you directly query its current value. 8 | * 9 | * This means that its current value is kept up to date regardless of observers. 10 | * How this is actually accomplished is up to the concrete class extending this trait. 11 | */ 12 | trait StrictSignal[+A] extends Signal[A] { 13 | 14 | /** Map StrictSignal to get another StrictSignal, without requiring an Owner. 15 | * 16 | * The mechanism is similar to Var.zoomLazy. 17 | * 18 | * Just as `zoomLazy`, this method will be renamed in the next major Airstream release. 19 | */ 20 | def mapLazy[B](project: A => B): StrictSignal[B] = { 21 | new LazyStrictSignal( 22 | parentSignal = this, 23 | zoomIn = project, 24 | parentDisplayName = displayName, 25 | displayNameSuffix = ".mapLazy" 26 | ) 27 | } 28 | 29 | /** Get the signal's current value 30 | * 31 | * @throws Throwable the error from the current value's Failure 32 | */ 33 | override def now(): A = super.now() 34 | 35 | override abstract def tryNow(): Try[A] = super.tryNow() 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/distinct/DistinctStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.distinct 2 | 3 | import com.raquo.airstream.common.{InternalTryObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{EventStream, Protected, Transaction} 5 | 6 | import scala.scalajs.js 7 | import scala.util.Try 8 | 9 | /** Emits only values that are distinct from the last emitted value, according to isSame function */ 10 | class DistinctStream[A]( 11 | override protected[this] val parent: EventStream[A], 12 | isSame: (Try[A], Try[A]) => Boolean, 13 | resetOnStop: Boolean 14 | ) extends SingleParentStream[A, A] with InternalTryObserver[A] { 15 | 16 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 17 | 18 | private var maybeLastSeenValue: js.UndefOr[Try[A]] = js.undefined 19 | 20 | override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 21 | val isDistinct = maybeLastSeenValue.map(!isSame(_, nextValue)).getOrElse(true) 22 | maybeLastSeenValue = nextValue 23 | if (isDistinct) { 24 | fireTry(nextValue, transaction) 25 | } 26 | } 27 | 28 | override protected[this] def onStop(): Unit = { 29 | if (resetOnStop) { 30 | maybeLastSeenValue = js.undefined 31 | } 32 | super.onStop() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/debug/Debugger.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.debug 2 | 3 | import scala.util.Try 4 | 5 | /** Debugger for observables 6 | * 7 | * @param onEvalFromParent Only for signals. Fired when signal calls `currentValueFromParent`, which happens 8 | * 1) when the signal is first started and its initial value is evaluated, AND 9 | * 2) also when the signal is re-started after being stopped, when that method is called 10 | * to re-sync this signal's value with the parent. 11 | * #TODO[Integrity]: I'm not 100% sure if this hook reports correctly. 12 | * - It actually reports the debugger signal's own calls to currentValueFromParent 13 | * and I'm not 100% sure that they match with the parent's calls. I think they 14 | * should match, at least if parent signal is always used via its debug signal, 15 | * but to be honest I'm not really sure. 16 | */ 17 | case class Debugger[-A]( 18 | onStart: () => Unit = () => (), 19 | onStop: () => Unit = () => (), 20 | onFire: Try[A] => Unit = (_: Try[A]) => (), 21 | onEvalFromParent: Try[A] => Unit = (_: Try[A]) => () 22 | ) 23 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/EitherObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{BaseObservable, Observable} 4 | 5 | /** See also: [[EitherStream]] */ 6 | class EitherObservable[A, B, Self[+_] <: Observable[_]](val observable: BaseObservable[Self, Either[A, B]]) extends AnyVal { 7 | 8 | /** Maps the value in Right(x) */ 9 | def mapRight[BB](project: B => BB): Self[Either[A, BB]] = { 10 | observable.map(_.map(project)) 11 | } 12 | 13 | /** Maps the value in Left(x) */ 14 | def mapLeft[AA](project: A => AA): Self[Either[AA, B]] = { 15 | observable.map(_.left.map(project)) 16 | } 17 | 18 | /** Maps the values in Left(y) and Right(x) */ 19 | def foldEither[C]( 20 | left: A => C, 21 | right: B => C 22 | ): Self[C] = { 23 | observable.map(_.fold(left, right)) 24 | } 25 | 26 | /** Maps Right(x) to Left(x), and Left(y) to Right(y) */ 27 | def swap: Self[Either[B, A]] = observable.map(_.swap) 28 | 29 | /** Maps Right(x) to Some(x), Left(_) to None */ 30 | def mapToOption: Self[Option[B]] = { 31 | observable.map(_.toOption) 32 | } 33 | 34 | /** Maps Right(_) to None, Left(x) to Some(x) */ 35 | def mapLeftToOption: Self[Option[A]] = { 36 | observable.map(_.left.toOption) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/CollectStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{EventStream, Protected, Transaction} 5 | 6 | import scala.util.Try 7 | 8 | /** This stream applies `fn` to the parent stream's events, and emits `x` from the resulting `Some(x)` value (if `None`, nothing is fired). 9 | * 10 | * This stream emits an error if the parent stream emits an error (Note: no filtering applied), or if `fn` throws 11 | * 12 | * @param fn Note: guarded against exceptions 13 | */ 14 | class CollectStream[A, B]( 15 | override protected[this] val parent: EventStream[A], 16 | fn: A => Option[B], 17 | ) extends SingleParentStream[A, B] with InternalNextErrorObserver[A] { 18 | 19 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 20 | 21 | override protected def onNext(nextParentValue: A, transaction: Transaction): Unit = { 22 | Try(fn(nextParentValue)).fold( 23 | onError(_, transaction), 24 | nextValue => nextValue.foreach(fireValue(_, transaction)) 25 | ) 26 | } 27 | 28 | override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { 29 | fireError(nextError, transaction) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /project/GenerateTupleSignals.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | import java.io.File 4 | 5 | case class GenerateTupleSignals( 6 | sourceDir: File, 7 | from: Int, 8 | to: Int 9 | ) extends SourceGenerator( 10 | sourceDir / "scala" / "com" / "raquo" / "airstream" / "extensions" / s"TupleSignals.scala" 11 | ) { 12 | 13 | override def apply(): Unit = { 14 | line("package com.raquo.airstream.extensions") 15 | line() 16 | line("import com.raquo.airstream.core.Signal") 17 | line("import com.raquo.airstream.misc.MapSignal") 18 | line() 19 | line("// #Warning do not edit this file directly, it is generated by GenerateTupleSignals.scala") 20 | line() 21 | line("// These mapN helpers are implicitly available on signals of tuples") 22 | line() 23 | for (n <- from to to) { 24 | enter(s"class TupleSignal${n}[${tupleType(n)}](val signal: Signal[(${tupleType(n)})]) extends AnyVal {", "}") { 25 | line() 26 | enter(s"def mapN[Out](project: (${tupleType(n)}) => Out): Signal[Out] = {", "}") { 27 | enter(s"new MapSignal[(${tupleType(n)}), Out](", ")") { 28 | line("parent = signal,") 29 | line(s"project = v => project(${tupleType(n, "v._")}),") 30 | line(s"recover = None") 31 | } 32 | } 33 | } 34 | line() 35 | line("// --") 36 | line() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/util/JsPriorityQueue.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.util 2 | 3 | import com.raquo.ew.JsArray 4 | 5 | class JsPriorityQueue[A](getRank: A => Int) { 6 | 7 | private[this] val queue: JsArray[A] = JsArray() 8 | 9 | def enqueue(item: A): Unit = { 10 | val itemRank = getRank(item) 11 | var insertAtIndex = 0 12 | var foundHigherRank = false 13 | while ({ 14 | insertAtIndex < queue.length && !foundHigherRank 15 | }) { 16 | if (getRank(queue(insertAtIndex)) > itemRank) { 17 | foundHigherRank = true 18 | } else { 19 | insertAtIndex += 1 20 | } 21 | } 22 | queue.splice(index = insertAtIndex, deleteCount = 0, item) // insert at index 23 | } 24 | 25 | /** Note: throws exception if there are no items in the queue */ 26 | @inline def dequeue(): A = { 27 | // We do this dance because JsArray.shift returns `js.undefined` if array is empty 28 | if (nonEmpty) { 29 | queue.shift() 30 | } else { 31 | throw new Exception("Unable to dequeue an empty JsPriorityQueue") 32 | } 33 | } 34 | 35 | def contains(item: A): Boolean = queue.indexOf(item) != -1 36 | 37 | @inline def size: Int = queue.length 38 | 39 | @inline def isEmpty: Boolean = size == 0 40 | 41 | @inline def nonEmpty: Boolean = !isEmpty 42 | 43 | def debugQueue: List[A] = queue.asScalaJs.toList 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/combine/CombineSignalN.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.combine 2 | 3 | import com.raquo.airstream.common.{InternalParentObserver, MultiParentSignal} 4 | import com.raquo.airstream.core.{Protected, Signal} 5 | import com.raquo.ew.JsArray 6 | 7 | import scala.util.Try 8 | 9 | /** 10 | * @param parents Never update this array - this signal owns it. 11 | * @param combinator Must not throw! Must be pure. 12 | */ 13 | class CombineSignalN[A, Out]( 14 | override protected[this] val parents: JsArray[Signal[A]], 15 | protected[this] val combinator: JsArray[A] => Out 16 | ) extends MultiParentSignal[A, Out] with CombineObservable[Out] { 17 | 18 | // @TODO[API] Maybe this should throw if parents.isEmpty 19 | 20 | override protected val topoRank: Int = Protected.maxTopoRank(0, parents) + 1 21 | 22 | override protected[this] val parentObservers: JsArray[InternalParentObserver[_]] = { 23 | parents.map { parent => 24 | InternalParentObserver.fromTry[A](parent, (_, trx) => { 25 | onInputsReady(trx) 26 | }) 27 | } 28 | } 29 | 30 | override protected[this] def inputsReady: Boolean = true 31 | 32 | override protected[this] def combinedValue: Try[Out] = { 33 | CombineObservable.jsArrayCombinator(parents.map(_.tryNow()), combinator) 34 | } 35 | 36 | override protected def currentValueFromParent(): Try[Out] = combinedValue 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/TrySignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.Signal 4 | 5 | import scala.util.Try 6 | 7 | class TrySignal[A](val signal: Signal[Try[A]]) extends AnyVal { 8 | 9 | /** This `.split`-s a signal of Try-s by their `isSuccess` property. 10 | * If you want a different key, use the .splitOne operator directly. 11 | * 12 | * @param success (initialSuccess, signalOfSuccessValues) => output 13 | * `success` is called whenever the parent signal switches from `Failure()` to `Success()`. 14 | * `signalOfSuccessValues` starts with `initialSuccess` value, and updates when 15 | * the parent signal updates from `Success(a)` to `Success(b)`. 16 | * @param failure (initialFailure, signalOfFailureValues) => output 17 | * `failure` is called whenever the parent stream switches from `Success()` to `Failure()`. 18 | * `signalOfFailureValues` starts with `initialFailure` value, and updates when 19 | * the parent signal updates from `Failure(a)` to `Failure(b)`. 20 | */ 21 | def splitTry[B]( 22 | success: (A, Signal[A]) => B, 23 | failure: (Throwable, Signal[Throwable]) => B 24 | ): Signal[B] = { 25 | new EitherSignal(signal.mapToEither).splitEither(failure, success) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/status/AsyncStatusObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.status 2 | 3 | import com.raquo.airstream.core.{BaseObservable, EventStream, Observable} 4 | 5 | import scala.scalajs.js 6 | 7 | /** Tracks the status of input and output of operator(stream). See [[Status]]. */ 8 | object AsyncStatusObservable { 9 | 10 | def apply[A, B, Self[+_] <: Observable[_]]( 11 | parent: BaseObservable[Self, A], 12 | operator: Self[A] => EventStream[B] 13 | ): Self[Status[A, B]] = { 14 | // #TODO[Integrity] Are those var-s 100% safe? 15 | // I think so, but it wouldn't hurt to test some weird transaction cases 16 | var ix = 0 17 | var maybeLastInput: js.UndefOr[A] = js.undefined 18 | 19 | val inputS = parent.map { input => 20 | ix = 0 21 | maybeLastInput = input 22 | input 23 | } 24 | 25 | val outputS = operator(inputS).map { output => 26 | ix += 1 27 | val lastInput = maybeLastInput.getOrElse(throw new Exception(s"${this}.asyncWithStatus: has output, but no input")) 28 | Resolved(lastInput, output, ix) 29 | } 30 | 31 | val pendingS = inputS.map(Pending(_)) 32 | 33 | pendingS.matchStreamOrSignal( 34 | ifStream = _.mergeWith(outputS), 35 | ifSignal = _.changes(_.mergeWith(outputS)) 36 | ).asInstanceOf[Self[Status[A, B]]] // #TODO[Integrity] How to type this properly? 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/FilterStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{EventStream, Protected, Transaction} 5 | 6 | import scala.util.Try 7 | 8 | // @TODO[API] Should we also offer a Try[A] => Boolean filter? Currently handled by .collect.recover combination 9 | /** This stream fires a subset of the events fired by its parent 10 | * 11 | * This stream emits an error if the parent stream emits an error (Note: no filtering applied), or if `passes` throws 12 | * 13 | * @param passes Note: guarded against exceptions 14 | */ 15 | class FilterStream[A]( 16 | override protected[this] val parent: EventStream[A], 17 | passes: A => Boolean 18 | ) extends SingleParentStream[A, A] with InternalNextErrorObserver[A] { 19 | 20 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 21 | 22 | override protected def onNext(nextParentValue: A, transaction: Transaction): Unit = { 23 | // @TODO[Performance] Can / should we replace internal Try()-s with try-catch blocks? 24 | Try(passes(nextParentValue)).fold( 25 | onError(_, transaction), 26 | passes => if (passes) fireValue(nextParentValue, transaction) 27 | ) 28 | } 29 | 30 | override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { 31 | fireError(nextError, transaction) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/debug/DebuggerObserver.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.debug 2 | 3 | import com.raquo.airstream.core.{AirstreamError, Observer} 4 | import com.raquo.airstream.core.AirstreamError.DebugError 5 | 6 | import scala.util.{Failure, Success, Try} 7 | 8 | /** See [[DebuggableObserver]] for user-facing debug methods */ 9 | class DebuggerObserver[A](parent: Observer[A], debug: Try[A] => Unit) extends Observer[A] { 10 | 11 | override def defaultDisplayName: String = { 12 | parent match { 13 | case _: DebuggerObserver[_] => 14 | // When chaining multiple debug observers, they will inherit the parent's displayName 15 | parent.displayName 16 | case _ => 17 | // We need to indicate that this isn't the original observer, but a debugged one, 18 | // otherwise debugging could get really confusing 19 | s"${parent.displayName}|Debug" 20 | } 21 | } 22 | 23 | override def onTry(nextValue: Try[A]): Unit = { 24 | try { 25 | debug(nextValue) 26 | } catch { 27 | case err: Throwable => 28 | val maybeCause = nextValue.toEither.left.toOption 29 | AirstreamError.sendUnhandledError(DebugError(err, cause = maybeCause)) 30 | } 31 | parent.onTry(nextValue) 32 | } 33 | 34 | final override def onNext(nextValue: A): Unit = onTry(Success(nextValue)) 35 | 36 | final override def onError(nextError: Throwable): Unit = onTry(Failure(nextError)) 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/airstream/split/SplitMatchOneObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{Observable, BaseObservable} 4 | import scala.annotation.compileTimeOnly 5 | import com.raquo.airstream.split.MacrosUtilities.{CaseAny, HandlerAny} 6 | 7 | /** 8 | * `MatchSplitObservable` served as macro's data holder for macro expansion. 9 | * 10 | * For example: 11 | * 12 | * ```scala 13 | * fooSignal.splitMatchOne 14 | * .handleCase { case Bar(Some(str)) => str } { (str, strSignal) => renderStrNode(str, strSignal) } 15 | * .handleCase { case baz: Baz => baz } { (baz, bazSignal) => renderBazNode(baz, bazSignal) } 16 | * ``` 17 | * 18 | * will be expanded sematically into: 19 | * 20 | * ```scala 21 | * MatchSplitObservable.build(fooSignal)(({ case baz: Baz => baz }), ({ case Bar(Some(str)) => str }))(...) 22 | * ``` 23 | */ 24 | 25 | final case class SplitMatchOneObservable[Self[+_] <: Observable[_] , I, O] private (private val underlying: Unit) extends AnyVal 26 | 27 | object SplitMatchOneObservable { 28 | 29 | @compileTimeOnly("`splitMatchOne` without `toSignal`/`toStream` is illegal") 30 | def build[Self[+_] <: Observable[_] , I, O]( 31 | observable: BaseObservable[Self, I] 32 | )( 33 | caseList: CaseAny* 34 | )( 35 | handleList: HandlerAny[O]* 36 | ): SplitMatchOneObservable[Self, I, O] = throw new UnsupportedOperationException("`splitMatchOne` without `toSignal`/`toStream` is illegal") 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/javaflow/FlowPublisherStreamSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.javaflow 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.core.EventStream 5 | import com.raquo.airstream.fixtures.{Effect, TestableOwner} 6 | import com.raquo.airstream.ownership.Owner 7 | 8 | import java.util.concurrent.Flow 9 | import scala.collection.mutable 10 | 11 | class FlowPublisherStreamSpec extends UnitSpec { 12 | 13 | class RangePublisher(range: Range) extends Flow.Publisher[Int] { 14 | def subscribe(subscriber: Flow.Subscriber[_ >: Int]): Unit = { 15 | val subscription = new Flow.Subscription { 16 | def request(n: Long): Unit = range.foreach(subscriber.onNext(_)) 17 | def cancel(): Unit = () 18 | } 19 | subscriber.onSubscribe(subscription) 20 | } 21 | } 22 | 23 | it("EventStream.fromPublisher") { 24 | 25 | implicit val owner: Owner = new TestableOwner 26 | 27 | val range = 1 to 3 28 | val stream = EventStream.fromPublisher(new RangePublisher(range)) 29 | 30 | val effects = mutable.Buffer[Effect[_]]() 31 | val sub1 = stream.foreach(newValue => effects += Effect("obs1", newValue)) 32 | 33 | effects.toList shouldBe range.map(i => Effect("obs1", i)) 34 | effects.clear() 35 | 36 | sub1.kill() 37 | 38 | val sub2 = stream.foreach(newValue => effects += Effect("obs2", newValue)) 39 | 40 | effects.toList shouldBe range.map(i => Effect("obs2", i)) 41 | effects.clear() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/split/SplittableSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.Signal 4 | 5 | class SplittableSignal[M[_], Input](val signal: Signal[M[Input]]) extends AnyVal { 6 | 7 | def split[Output, Key]( 8 | key: Input => Key, 9 | distinctCompose: Signal[Input] => Signal[Input] = _.distinct, 10 | duplicateKeys: DuplicateKeysConfig = DuplicateKeysConfig.default 11 | )( 12 | project: (Key, Input, Signal[Input]) => Output 13 | )(implicit splittable: Splittable[M] 14 | ): Signal[M[Output]] = { 15 | new SplitSignal[M, Input, Output, Key]( 16 | parent = signal, 17 | key, 18 | distinctCompose, 19 | project, 20 | splittable, 21 | duplicateKeys 22 | ) 23 | } 24 | 25 | /** Like `split`, but uses index of the item in the list as the key. */ 26 | def splitByIndex[Output]( 27 | project: (Int, Input, Signal[Input]) => Output 28 | )(implicit splittable: Splittable[M] 29 | ): Signal[M[Output]] = { 30 | new SplitSignal[M, (Input, Int), Output, Int]( 31 | parent = signal.map(splittable.zipWithIndex), 32 | key = _._2, // Index 33 | distinctCompose = _.distinctBy(_._1), 34 | project = (index: Int, initialTuple, tupleSignal) => { 35 | project(index, initialTuple._1, tupleSignal.map(_._1)) 36 | }, 37 | splittable, 38 | DuplicateKeysConfig.noWarnings // No need to check for duplicates – we know the keys are good. 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/VarSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.{Transaction, WritableSignal} 4 | 5 | import scala.util.Try 6 | 7 | /** Unlike other signals, this signal's current value is always up to date 8 | * because a subscription is not needed to maintain it, we just call onTry 9 | * whenever the Var's current value is updated. 10 | * 11 | * Consequently, we expose its current value with now() / tryNow() methods 12 | * (see StrictSignal). 13 | */ 14 | private[state] class VarSignal[A] private[state] ( 15 | initial: Try[A], 16 | parentDisplayName: => String 17 | ) extends WritableSignal[A] with StrictSignal[A] { 18 | 19 | /** SourceVar does not directly depend on other observables, so it breaks the graph. */ 20 | override protected val topoRank: Int = 1 21 | 22 | setCurrentValue(initial) 23 | 24 | /** Note: we do not check if isStarted() here, this is how we ensure that this 25 | * signal's current value stays up to date. If this signal is stopped, this 26 | * value will not be propagated anywhere further though. 27 | */ 28 | private[state] def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 29 | fireTry(nextValue, transaction) 30 | } 31 | 32 | override protected def currentValueFromParent(): Try[A] = tryNow() // noop 33 | 34 | override protected def onWillStart(): Unit = () // noop 35 | 36 | override protected def defaultDisplayName: String = parentDisplayName + ".signal" 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/ownership/Subscription.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.ownership 2 | 3 | // @TODO[API] Change cleanup from () => Unit to : => Unit? Would it be possible to override such a field? 4 | /** Represents a leaky resource that needs to be cleaned up. 5 | * 6 | * Subscription is linked for its life to a given owner. 7 | * It can be killed by that owner, or externally using .kill(). 8 | * 9 | * See also: Ownership documentation, as well as [[Owner]] scaladoc 10 | * 11 | * @param cleanup Note: Must not throw! 12 | */ 13 | class Subscription( 14 | private[ownership] val owner: Owner, 15 | cleanup: () => Unit 16 | ) { 17 | 18 | /** Make sure we only kill any given Subscription once. Just a sanity check against bad user logic, 19 | * e.g. calling .kill() manually when `owner` has already killed this subscription. 20 | */ 21 | final private[this] var _isKilled = false 22 | 23 | owner.own(this) 24 | 25 | def isKilled: Boolean = _isKilled 26 | 27 | def kill(): Unit = { 28 | safeCleanup() 29 | owner.onKilledExternally(this) 30 | } 31 | 32 | // @TODO[API] This method exists only due to permissions. Is there another way? 33 | @inline private[ownership] def onKilledByOwner(): Unit = { 34 | safeCleanup() 35 | } 36 | 37 | private[this] def safeCleanup(): Unit = { 38 | if (!_isKilled) { 39 | cleanup() 40 | _isKilled = true 41 | } else { 42 | throw new Exception("Can not kill Subscription: it was already killed.") 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/MultiParentSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.{Protected, Signal, WritableSignal} 4 | import com.raquo.ew.JsArray 5 | 6 | /** A simple signal that has multiple parents. */ 7 | trait MultiParentSignal[I, O] extends WritableSignal[O] { 8 | 9 | /** This array is read-only, never update it. */ 10 | protected[this] val parents: JsArray[Signal[I]] 11 | 12 | protected[this] lazy val _parentLastUpdateIds: JsArray[Int] = parents.map(Protected.lastUpdateId(_)) 13 | 14 | override protected def onWillStart(): Unit = { 15 | parents.forEach(Protected.maybeWillStart(_)) 16 | val shouldPullFromParent = updateParentLastUpdateIds() 17 | if (shouldPullFromParent) { 18 | updateCurrentValueFromParent() 19 | } 20 | } 21 | 22 | /** @return Whether parent has emitted since last time we checked */ 23 | protected[this] def updateParentLastUpdateIds(): Boolean = { 24 | var parentHasUpdated = false 25 | parents.forEachWithIndex { (parent, ix) => 26 | val newLastUpdateId = Protected.lastUpdateId(parent) 27 | val lastSeenParentUpdateId = _parentLastUpdateIds(ix) 28 | if (newLastUpdateId != lastSeenParentUpdateId) { 29 | _parentLastUpdateIds.update(ix, newLastUpdateId) 30 | parentHasUpdated = true 31 | } 32 | } 33 | parentHasUpdated 34 | } 35 | 36 | protected def updateCurrentValueFromParent(): Unit = { 37 | val nextValue = currentValueFromParent() 38 | setCurrentValue(nextValue) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/debug/DebuggerStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.debug 2 | 3 | import com.raquo.airstream.common.SingleParentStream 4 | import com.raquo.airstream.core.{EventStream, Protected, Transaction} 5 | 6 | import scala.util.{Failure, Success, Try} 7 | 8 | /** See [[DebuggableObservable]] and [[DebuggableSignal]] for user-facing debug methods */ 9 | class DebuggerStream[A]( 10 | override protected[this] val parent: EventStream[A], 11 | override protected val debugger: Debugger[A] 12 | ) extends SingleParentStream[A, A] with DebuggerObservable[A] { 13 | 14 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 15 | 16 | override protected def defaultDisplayName: String = DebuggerObservable.defaultDisplayName(parent) 17 | 18 | override protected[this] def fireValue(nextValue: A, transaction: Transaction): Unit = { 19 | debugFireTry(Success(nextValue)) 20 | super.fireValue(nextValue, transaction) 21 | } 22 | 23 | override protected[this] def fireError(nextError: Throwable, transaction: Transaction): Unit = { 24 | debugFireTry(Failure(nextError)) 25 | super.fireError(nextError, transaction) 26 | } 27 | 28 | override protected[this] def onStart(): Unit = { 29 | super.onStart() 30 | debugOnStart() 31 | } 32 | 33 | override protected[this] def onStop(): Unit = { 34 | super.onStop() 35 | debugOnStop() 36 | } 37 | 38 | override protected def onTry(nextParentValue: Try[A], transaction: Transaction): Unit = { 39 | fireTry(nextParentValue, transaction) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/timing/SyncDelayStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.timing 2 | 3 | import com.raquo.airstream.common.{InternalTryObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{Observable, Protected, SyncObservable, Transaction} 5 | 6 | import scala.scalajs.js 7 | import scala.util.Try 8 | 9 | /** Note: This is generally supposed to be used only with streams as inputs. 10 | * Make sure you know what you're doing if using signals. 11 | * - if `parent` is a Signal, this stream mirrors `parent.changes`, not `parent`. 12 | * - if `after` is a Signal, this stream ignores its initial value 13 | */ 14 | class SyncDelayStream[A]( 15 | override protected[this] val parent: Observable[A], 16 | after: Observable[_] 17 | ) extends SingleParentStream[A, A] with InternalTryObserver[A] with SyncObservable[A] { 18 | 19 | private[this] var maybePendingValue: js.UndefOr[Try[A]] = js.undefined 20 | 21 | override protected val topoRank: Int = (Protected.topoRank(parent) max Protected.topoRank(after)) + 1 22 | 23 | override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 24 | if (!transaction.containsPendingObservable(this)) { 25 | transaction.enqueuePendingObservable(this) 26 | } 27 | maybePendingValue = nextValue 28 | } 29 | 30 | override private[airstream] def syncFire(transaction: Transaction): Unit = { 31 | maybePendingValue.foreach { pendingValue => 32 | maybePendingValue = js.undefined 33 | fireTry(pendingValue, transaction) 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/airstream/split/SplitMatchOneValueObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{Observable, BaseObservable} 4 | import scala.annotation.compileTimeOnly 5 | import com.raquo.airstream.split.MacrosUtilities.{CaseAny, HandlerAny, MatchValueHandler} 6 | 7 | /** `MatchSingletonObservable` served as macro's data holder for macro expansion. 8 | * 9 | * For example: 10 | * 11 | * {{{ 12 | * fooSignal.splitMatchOne 13 | * .splitValue(Tar)(tarSignal => renderTarNode(tarSignal)) 14 | * }}} 15 | * 16 | * will be expanded sematically into: 17 | * 18 | * {{{ 19 | * MatchTypeObservable.build[*, *, *, Baz](fooSignal)()(???)({ case Tar => Tar }) 20 | * }}} 21 | * 22 | * and then into: 23 | * 24 | * {{{ 25 | * MatchSplitObservable.build(fooSignal)({ case Tar => Tar })(???) 26 | * }}} 27 | */ 28 | 29 | final case class SplitMatchOneValueObservable[Self[+_] <: Observable[_], I, O, V] private (private val underlying: Unit) extends AnyVal 30 | 31 | object SplitMatchOneValueObservable { 32 | 33 | @compileTimeOnly("`splitMatchOne` without `toSignal`/`toStream` is illegal") 34 | def build[Self[+_] <: Observable[_], I, O, V]( 35 | observable: BaseObservable[Self, I] 36 | )( 37 | caseList: CaseAny* 38 | )( 39 | handleList: HandlerAny[O]* 40 | )( 41 | valueHandler: MatchValueHandler[V] 42 | ): SplitMatchOneValueObservable[Self, I, O, V] = 43 | throw new UnsupportedOperationException( 44 | "`splitMatchOne` without `toSignal`/`toStream` is illegal" 45 | ) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/StatusObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{BaseObservable, Observable} 4 | import com.raquo.airstream.status.{Pending, Resolved, Status} 5 | 6 | class StatusObservable[In, Out, Self[+_] <: Observable[_]](val observable: BaseObservable[Self, Status[In, Out]]) extends AnyVal { 7 | 8 | /** Map the output value in Resolved */ 9 | def mapOutput[Out2](project: Out => Out2): Self[Status[In, Out2]] = { 10 | observable.map(_.mapOutput(project)) 11 | } 12 | 13 | /** Map the input value in both Pending and Resolved */ 14 | def mapInput[In2](project: In => In2): Self[Status[In2, Out]] = { 15 | observable.map(_.mapInput(project)) 16 | } 17 | 18 | /** Map Resolved(...) to Right(project(Resolved(...))), and Pending(y) to Left(Pending(y)) */ 19 | def mapResolved[B]( 20 | project: Resolved[In, Out] => B 21 | ): Self[Either[Pending[In], B]] = { 22 | foldStatus(r => Right(project(r)), Left(_)) 23 | } 24 | 25 | /** Map Pending(x) to Left(project(Pending(x))), and Resolved(...) to Right(Resolved(...)) */ 26 | def mapPending[B]( 27 | project: Pending[In] => B 28 | ): Self[Either[B, Resolved[In, Out]]] = { 29 | foldStatus(Right(_), p => Left(project(p))) 30 | } 31 | 32 | /** Map Resolved(...) with `resolved`, and Pending(x) with `pending`, to a common type */ 33 | def foldStatus[B]( 34 | resolved: Resolved[In, Out] => B, 35 | pending: Pending[In] => B 36 | ): Self[B] = { 37 | observable.map(_.fold(resolved, pending)) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/split/SplittableOneStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{EventStream, Signal} 4 | 5 | class SplittableOneStream[Input](val stream: EventStream[Input]) extends AnyVal { 6 | 7 | /** This operator works like `split`, but with only one item, not a collection of items. */ 8 | def splitOne[Output, Key]( 9 | key: Input => Key 10 | )( 11 | project: (Key, Input, Signal[Input]) => Output 12 | ): EventStream[Output] = { 13 | // @TODO[Performance] Would be great if we didn't need .toWeakSignal and .map, but I can't figure out how to do that 14 | // Note: We never have duplicate keys here, so we can use 15 | // DuplicateKeysConfig.noWarnings to improve performance 16 | new SplittableSignal(stream.toWeakSignal) 17 | .split( 18 | key, 19 | distinctCompose = identity, 20 | DuplicateKeysConfig.noWarnings 21 | )( 22 | project 23 | ) 24 | .changes 25 | .map(_.get) 26 | } 27 | 28 | /** This operator lets you "split" EventStream[Input] into two branches: 29 | * - processing of Signal[Input] into Output, and 30 | * - the initial value of Output. 31 | * This is a nice shorthand to signal.splitOption in cases 32 | * when signal is actually stream.toWeakSignal or stream.startWith(initial) 33 | */ 34 | def splitStart[Output]( 35 | project: (Input, Signal[Input]) => Output, 36 | startWith: Output 37 | ): Signal[Output] = { 38 | stream.toWeakSignal.splitOption(project, startWith) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/airstream/split/SplitMatchOneTypeObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{Observable, BaseObservable} 4 | import scala.annotation.compileTimeOnly 5 | import com.raquo.airstream.split.MacrosUtilities.{CaseAny, HandlerAny, MatchTypeHandler} 6 | 7 | /** `MatchTypeObservable` served as macro's data holder for macro expansion. 8 | * 9 | * For example: 10 | * 11 | * {{{ 12 | * fooSignal.splitMatchOne 13 | * .handleType[Baz] { (baz, bazSignal) => renderBazNode(baz, bazSignal) } 14 | * }}} 15 | * 16 | * will be expanded sematically into: 17 | * 18 | * {{{ 19 | * MatchTypeObservable.build[*, *, *, Baz](fooSignal)()(???)({ case t: Baz => t }) 20 | * }}} 21 | * 22 | * and then into: 23 | * 24 | * {{{ 25 | * MatchSplitObservable.build(fooSignal)({ case baz: Baz => baz })(???) 26 | * }}} 27 | */ 28 | 29 | final case class SplitMatchOneTypeObservable[Self[+_] <: Observable[_], I, O, T] private (private val underlying: Unit) extends AnyVal 30 | 31 | object SplitMatchOneTypeObservable { 32 | 33 | @compileTimeOnly("`splitMatchOne` without `toSignal`/`toStream` is illegal") 34 | def build[Self[+_] <: Observable[_], I, O, T]( 35 | observable: BaseObservable[Self, I] 36 | )( 37 | caseList: CaseAny* 38 | )( 39 | handleList: HandlerAny[O]* 40 | )( 41 | tHandler: MatchTypeHandler[T] 42 | ): SplitMatchOneTypeObservable[Self, I, O, T] = 43 | throw new UnsupportedOperationException( 44 | "`splitMatchOne` without `toSignal`/`toStream` is illegal" 45 | ) 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/split/SplittableStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import com.raquo.airstream.core.{EventStream, Signal} 4 | 5 | class SplittableStream[M[_], Input](val stream: EventStream[M[Input]]) extends AnyVal { 6 | 7 | def split[Output, Key]( 8 | key: Input => Key, 9 | distinctCompose: Signal[Input] => Signal[Input] = _.distinct, 10 | duplicateKeys: DuplicateKeysConfig = DuplicateKeysConfig.default 11 | )( 12 | project: (Key, Input, Signal[Input]) => Output 13 | )(implicit splittable: Splittable[M] 14 | ): Signal[M[Output]] = { 15 | new SplitSignal[M, Input, Output, Key]( 16 | parent = stream.startWith(splittable.empty, cacheInitialValue = true), 17 | key, 18 | distinctCompose, 19 | project, 20 | splittable, 21 | duplicateKeys 22 | ) 23 | } 24 | 25 | /** Like `split`, but uses index of the item in the list as the key. */ 26 | def splitByIndex[Output]( 27 | project: (Int, Input, Signal[Input]) => Output 28 | )(implicit splittable: Splittable[M] 29 | ): Signal[M[Output]] = { 30 | new SplitSignal[M, (Input, Int), Output, Int]( 31 | parent = stream.map(splittable.zipWithIndex).startWith(splittable.empty, cacheInitialValue = true), 32 | key = _._2, // Index 33 | distinctCompose = _.distinctBy(_._1), 34 | project = (index: Int, initialTuple, tupleSignal) => { 35 | project(index, initialTuple._1, tupleSignal.map(_._1)) 36 | }, 37 | splittable, 38 | DuplicateKeysConfig.noWarnings // No need to check for duplicates – we know the keys are good. 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/Sink.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | import scala.scalajs.js 4 | 5 | /** 6 | * A Sink is something that can be converted to an [[Observer]]. 7 | * The counterparty to Sink is a [[Source]], something that can be converted to an [[Observable]]. 8 | * 9 | * A Sink could be an Observer itself, an EventBus, a Var, or, via implicits, an external type like js.Function1. 10 | * 11 | * The point of using Sink instead of Observer in your API is to let the end users 12 | * pass simply `eventBus` instead of `eventBus.writer` to a method that requires Sink, 13 | * and to achieve that without having an implicit conversion from EventBus to Observer, 14 | * because then you'd also want an implicit conversion from EventBus to Observable, and 15 | * those two would be incompatible (e.g. both Observable and Observer have a filter method). 16 | */ 17 | trait Sink[-A] { 18 | def toObserver: Observer[A] 19 | } 20 | 21 | object Sink { 22 | 23 | // @TODO[Scala3] 24 | // - Unfortunately I can't get callbackToSink to work in Laminar because the type inference 25 | // fails if you provide a lambda (without type ascription) like (v => println(v)) where 26 | // a Sink[String] is expected. 27 | // - Check this again in Scala 3 28 | 29 | // implicit def callbackToSink[A](callback: A => Unit): Sink[A] = new Sink[A] { 30 | // override def toObserver: Observer[A] = Observer(callback) 31 | // } 32 | 33 | implicit def jsCallbackToSink[A](callback: js.Function1[A, Unit]): Sink[A] = new Sink[A] { 34 | override def toObserver: Observer[A] = Observer(callback) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/custom/CustomSignalSource.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.custom 2 | 3 | import com.raquo.airstream.core.{Signal, Transaction, WritableSignal} 4 | import com.raquo.airstream.custom.CustomSource._ 5 | 6 | import scala.util.{Success, Try} 7 | 8 | // @TODO[Test] needs testing 9 | 10 | /** Use this to easily create a custom signal from an external source 11 | * 12 | * See docs on custom sources, and [[CustomSource.Config]] 13 | */ 14 | class CustomSignalSource[A]( 15 | getInitialValue: => Try[A], 16 | makeConfig: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => CustomSource.Config 17 | ) extends WritableSignal[A] with CustomSource[A] { 18 | 19 | override protected[this] val config: Config = makeConfig( 20 | value => Transaction(fireTry(value, _)), 21 | () => tryNow(), 22 | () => startIndex, 23 | () => isStarted 24 | ) 25 | 26 | override protected def currentValueFromParent(): Try[A] = getInitialValue 27 | } 28 | 29 | object CustomSignalSource { 30 | 31 | @deprecated("Use Signal.fromCustomSource", "15.0.0-M1") 32 | def apply[A]( 33 | initial: => A 34 | )( 35 | config: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => Config 36 | ): Signal[A] = { 37 | new CustomSignalSource[A](Success(initial), config) 38 | } 39 | 40 | @deprecated("Use Signal.fromCustomSource", "15.0.0-M1") 41 | def fromTry[A]( 42 | initial: => Try[A] 43 | )( 44 | config: (SetCurrentValue[A], GetCurrentValue[A], GetStartIndex, GetIsStarted) => Config 45 | ): Signal[A] = { 46 | new CustomSignalSource[A](initial, config) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/TryObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{BaseObservable, Observable} 4 | 5 | import scala.util.Try 6 | 7 | /** See also: [[OptionStream]] */ 8 | class TryObservable[A, Self[+_] <: Observable[_]](val observable: BaseObservable[Self, Try[A]]) extends AnyVal { 9 | 10 | /** Maps the value in Success(x) */ 11 | def mapSuccess[B](project: A => B): Self[Try[B]] = { 12 | observable.map(_.map(project)) 13 | } 14 | 15 | /** Maps the value in Failure(x) */ 16 | def mapFailure(project: Throwable => Throwable): Self[Try[A]] = { 17 | observable.map(_.toEither.left.map(project).toTry) 18 | } 19 | 20 | /** Maps the values in Success(x) and Failure(y) to a common type */ 21 | def foldTry[B]( 22 | failure: Throwable => B, 23 | success: A => B, 24 | ): Self[B] = { 25 | observable.map(_.fold(failure, success)) 26 | } 27 | 28 | /** Convert the Try to Either */ 29 | def mapToEither: Self[Either[Throwable, A]] = { 30 | observable.map(_.toEither) 31 | } 32 | 33 | /** Convert the Try to Either and map the value in Left(x) */ 34 | def mapToEither[L](left: Throwable => L): Self[Either[L, A]] = { 35 | observable.map(_.toEither.left.map(left)) 36 | } 37 | 38 | /** Merge Try[A] into Try[AA >: A] by mapping the value in Failure(y) */ 39 | def recoverFailure[AA >: A](f: Throwable => AA): Self[AA] = { 40 | observable.map(_.fold(f, identity)) 41 | } 42 | 43 | /** Unwrap Try to "undo" `recoverToTry` – Encode Failure(err) as observable errors, and Success(v) as events */ 44 | def throwFailure: Self[A] = { 45 | observable.map(_.fold(throw _, identity)) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/ScanLeftSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.SingleParentSignal 4 | import com.raquo.airstream.core.{Observable, Protected, Signal, Transaction} 5 | 6 | import scala.util.Try 7 | 8 | /** Note: In folds, failure is often toxic to all subsequent events. 9 | * You often can not satisfactorily recover from a failure downstream 10 | * because you will not have access to previous non-failed state in `fn` 11 | * Therefore, make sure to handle recoverable errors in `fn`. 12 | * 13 | * @param makeInitialValue Note: Must not throw! 14 | * @param fn Note: Must not throw! 15 | */ 16 | class ScanLeftSignal[A, B]( 17 | override protected[this] val parent: Observable[A], 18 | makeInitialValue: () => Try[B], 19 | fn: (Try[B], Try[A]) => Try[B] 20 | ) extends SingleParentSignal[A, B] { 21 | 22 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 23 | 24 | /** #Note: this is called from tryNow(), make sure to avoid infinite loop. */ 25 | override protected def currentValueFromParent(): Try[B] = { 26 | if (parentIsSignal) { 27 | val parentSignal = parent.asInstanceOf[Signal[A @unchecked]] 28 | maybeLastSeenCurrentValue 29 | .map(lastSeenCurrentValue => fn(lastSeenCurrentValue, parentSignal.tryNow())) 30 | .getOrElse(makeInitialValue()) 31 | } else { 32 | maybeLastSeenCurrentValue 33 | .getOrElse(makeInitialValue()) 34 | } 35 | } 36 | 37 | override protected def onTry(nextParentValue: Try[A], transaction: Transaction): Unit = { 38 | super.onTry(nextParentValue, transaction) 39 | fireTry(fn(tryNow(), nextParentValue), transaction) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/state/OwnedSignalSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.eventbus.EventBus 5 | import com.raquo.airstream.fixtures.{Calculation, TestableOwner} 6 | 7 | import scala.collection.mutable 8 | import scala.util.Success 9 | 10 | class OwnedSignalSpec extends UnitSpec { 11 | 12 | it("OwnedSignal") { 13 | 14 | implicit val testOwner: TestableOwner = new TestableOwner 15 | 16 | val calculations = mutable.Buffer[Calculation[Int]]() 17 | 18 | val bus = new EventBus[Int] 19 | 20 | val signal = bus.events 21 | .map(Calculation.log("bus", calculations)) 22 | .map(_ * 10) 23 | .startWith(-1) 24 | .map(Calculation.log("signal", calculations)) 25 | 26 | // -- 27 | 28 | bus.writer.onNext(1) 29 | 30 | calculations shouldBe mutable.Buffer() 31 | 32 | // -- 33 | 34 | val signalViewer = signal.observe 35 | 36 | calculations shouldBe mutable.Buffer(Calculation("signal", -1)) 37 | calculations.clear() 38 | 39 | // -- 40 | 41 | signalViewer.now() shouldBe -1 42 | signalViewer.tryNow() shouldBe Success(-1) 43 | 44 | calculations shouldBe mutable.Buffer() 45 | 46 | // -- 47 | 48 | bus.writer.onNext(2) 49 | 50 | calculations shouldBe mutable.Buffer(Calculation("bus", 2), Calculation("signal", 20)) 51 | calculations.clear() 52 | 53 | signalViewer.now() shouldBe 20 54 | 55 | calculations shouldBe mutable.Buffer() 56 | 57 | // -- 58 | 59 | signalViewer.killOriginalSubscription() 60 | bus.writer.onNext(3) 61 | 62 | signalViewer.now() shouldBe 20 63 | 64 | calculations shouldBe mutable.Buffer() 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/EitherSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.Signal 4 | import com.raquo.airstream.split.SplittableOneSignal 5 | 6 | class EitherSignal[A, B](val signal: Signal[Either[A, B]]) extends AnyVal { 7 | 8 | /** This `.split`-s a Signal of an Either by the Either's `isRight` property. 9 | * If you want a different key, use the .splitOne operator directly. 10 | * 11 | * @param left (initialLeft, signalOfLeftValues) => output 12 | * `left` is called whenever parent signal switches from `Right()` to `Left()`. 13 | * `signalOfLeftValues` starts with `initialLeft` value, and updates when 14 | * the parent signal updates from `Left(a)` to `Left(b)`. 15 | * @param right (initialRight, signalOfRightValues) => output 16 | * `right` is called whenever parent signal switches from `Left()` to `Right()`. 17 | * `signalOfRightValues` starts with `initialRight` value, and updates when 18 | * the parent signal updates from `Right(a) to `Right(b)`. 19 | */ 20 | def splitEither[C]( 21 | left: (A, Signal[A]) => C, 22 | right: (B, Signal[B]) => C 23 | ): Signal[C] = { 24 | new SplittableOneSignal(signal).splitOne(key = _.isRight) { 25 | (_, initial, signal) => 26 | initial match { 27 | case Right(v) => 28 | right(v, signal.map(e => e.getOrElse(throw new Exception(s"splitEither: `${signal}` bad right value: $e")))) 29 | case Left(v) => 30 | left(v, signal.map(e => e.left.getOrElse(throw new Exception(s"splitEither: `${signal}` bad left value: $e")))) 31 | } 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /project/GenerateTupleStreams.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | import java.io.File 4 | 5 | case class GenerateTupleStreams( 6 | sourceDir: File, 7 | from: Int, 8 | to: Int 9 | ) extends SourceGenerator( 10 | sourceDir / "scala" / "com" / "raquo" / "airstream" / "extensions" / s"TupleStreams.scala" 11 | ) { 12 | 13 | override def apply(): Unit = { 14 | line("package com.raquo.airstream.extensions") 15 | line() 16 | line("import com.raquo.airstream.core.EventStream") 17 | line("import com.raquo.airstream.misc.{FilterStream, MapStream}") 18 | line() 19 | line("// #Warning do not edit this file directly, it is generated by GenerateTupleStreams.scala") 20 | line() 21 | line("// These mapN and filterN helpers are implicitly available on streams of tuples") 22 | line() 23 | for (n <- from to to) { 24 | enter(s"class TupleStream${n}[${tupleType(n)}](val stream: EventStream[(${tupleType(n)})]) extends AnyVal {", "}") { 25 | line() 26 | enter(s"def mapN[Out](project: (${tupleType(n)}) => Out): EventStream[Out] = {", "}") { 27 | enter(s"new MapStream[(${tupleType(n)}), Out](", ")") { 28 | line("parent = stream,") 29 | line(s"project = v => project(${tupleType(n, "v._")}),") 30 | line(s"recover = None") 31 | } 32 | } 33 | line() 34 | enter(s"def filterN(passes: (${tupleType(n)}) => Boolean): EventStream[(${tupleType(n)})] = {", "}") { 35 | enter(s"new FilterStream[(${tupleType(n)})](", ")") { 36 | line("parent = stream,") 37 | line(s"passes = v => passes(${tupleType(n, "v._")})") 38 | } 39 | } 40 | } 41 | line() 42 | line("// --") 43 | line() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/timing/DelayStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.timing 2 | 3 | import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{EventStream, Transaction} 5 | import com.raquo.ew.JsArray 6 | 7 | import scala.scalajs.js 8 | import scala.scalajs.js.timers.SetTimeoutHandle 9 | 10 | class DelayStream[A]( 11 | override protected[this] val parent: EventStream[A], 12 | delayMs: Int 13 | ) extends SingleParentStream[A, A] with InternalNextErrorObserver[A] { 14 | 15 | /** Async stream, so reset rank */ 16 | override protected val topoRank: Int = 1 17 | 18 | private val timerHandles: JsArray[SetTimeoutHandle] = JsArray() 19 | 20 | override protected def onNext(nextValue: A, transaction: Transaction): Unit = { 21 | var timerHandle: SetTimeoutHandle = null 22 | timerHandle = js.timers.setTimeout(delayMs.toDouble) { 23 | // println(s"> init trx from DelayEventStream.onNext($nextValue)") 24 | timerHandles.splice(timerHandles.indexOf(timerHandle), deleteCount = 1) // Remove handle 25 | Transaction(fireValue(nextValue, _)) 26 | () 27 | } 28 | timerHandles.push(timerHandle) 29 | } 30 | 31 | override def onError(nextError: Throwable, transaction: Transaction): Unit = { 32 | var timerHandle: SetTimeoutHandle = null 33 | timerHandle = js.timers.setTimeout(delayMs.toDouble) { 34 | timerHandles.splice(timerHandles.indexOf(timerHandle), deleteCount = 1) // Remove handle 35 | Transaction(fireError(nextError, _)) 36 | () 37 | } 38 | timerHandles.push(timerHandle) 39 | } 40 | 41 | override protected[this] def onStop(): Unit = { 42 | timerHandles.forEach(js.timers.clearTimeout(_)) 43 | timerHandles.length = 0 // Clear array 44 | super.onStop() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/combine/CombineSeqSignalSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.combine 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.core.{Observer, Signal} 5 | import com.raquo.airstream.fixtures.{Effect, TestableOwner} 6 | import com.raquo.airstream.state.Var 7 | 8 | import scala.collection.mutable 9 | 10 | class CombineSeqSignalSpec extends UnitSpec { 11 | 12 | it("should work as expected") { 13 | 14 | implicit val testOwner: TestableOwner = new TestableOwner 15 | 16 | val vars = (1 to 10).map(Var(_)) 17 | val seqSignal = Signal.combineSeq(vars.map(_.signal)) 18 | 19 | val effects = mutable.Buffer[Effect[Seq[Int]]]() 20 | 21 | val observer = Observer[Seq[Int]](effects += Effect("combined", _)) 22 | 23 | // -- 24 | 25 | effects.shouldBeEmpty 26 | 27 | // -- 28 | 29 | val subscription = seqSignal.addObserver(observer) 30 | 31 | // -- 32 | 33 | effects.toList shouldBe List( 34 | Effect("combined", (1 to 10)), 35 | ) 36 | 37 | // -- 38 | 39 | for (iteration <- 0 until 10) { 40 | for (signalToUpdate <- vars.indices) { 41 | effects.clear() 42 | vars(signalToUpdate).update(_ + 1) 43 | effects.toList shouldBe (List( 44 | Effect("combined", 45 | vars.indices.map { index => 46 | if (index > signalToUpdate) { 47 | index + 1 + // initial 48 | iteration // increased in prev iterations 49 | } else { 50 | index + 1 + // initial 51 | iteration + // increased in prev iterations 52 | 1 // increased in this iterations 53 | } 54 | } 55 | ) 56 | )) 57 | } 58 | } 59 | subscription.kill() 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/core/SharedStartStreamSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.fixtures.Effect 5 | import com.raquo.airstream.ownership.{DynamicOwner, DynamicSubscription, Subscription} 6 | 7 | import scala.collection.mutable 8 | 9 | class SharedStartStreamSpec extends UnitSpec { 10 | 11 | it("EventStream.fromValue() / CustomStreamSource") { 12 | 13 | val dynOwner = new DynamicOwner(() => throw new Exception("Accessing dynamic owner after it is killed")) 14 | 15 | val stream = EventStream.fromValue(1) 16 | 17 | val effects = mutable.Buffer[Effect[Int]]() 18 | 19 | val obs1 = Observer[Int](effects += Effect("obs1", _)) 20 | val obs2 = Observer[Int](effects += Effect("obs2", _)) 21 | 22 | // Wrapping in DynamicSubscription put us inside a Transaction.shared block. 23 | // This is (kind of) similar to how Laminar activates multiple subscriptions. 24 | DynamicSubscription.unsafe( 25 | dynOwner, 26 | activate = { o => 27 | val sub1 = stream.addObserver(obs1)(o) 28 | val sub2 = stream.addObserver(obs2)(o) 29 | new Subscription(o, cleanup = () => { 30 | sub1.kill() 31 | sub2.kill() 32 | }) 33 | } 34 | ) 35 | 36 | assert(effects.isEmpty) 37 | 38 | // -- 39 | 40 | dynOwner.activate() 41 | 42 | assert(effects.toList == List( 43 | Effect("obs1", 1), 44 | Effect("obs2", 1), 45 | )) 46 | effects.clear() 47 | 48 | // -- 49 | 50 | dynOwner.deactivate() 51 | assert(effects.isEmpty) 52 | 53 | // -- 54 | 55 | dynOwner.activate() 56 | 57 | assert(effects.toList == List( 58 | Effect("obs1", 1), 59 | Effect("obs2", 1), 60 | )) 61 | effects.clear() 62 | 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /project/SourceGenerator.scala: -------------------------------------------------------------------------------- 1 | import java.io.{File, FileOutputStream, PrintStream} 2 | 3 | abstract class SourceGenerator(outputFile: File) { 4 | 5 | private lazy val printStream = { 6 | outputFile.getParentFile.mkdirs() 7 | new PrintStream(new FileOutputStream(outputFile)) 8 | } 9 | 10 | private val indentStep = " " 11 | 12 | private var currentIndent = "" 13 | 14 | /** Override this to implement the generator. */ 15 | protected def apply(): Unit 16 | 17 | /** Call this to run the generator. */ 18 | final def run: List[File] = { 19 | apply() 20 | printStream.close() 21 | List(outputFile) 22 | } 23 | 24 | protected def enter(prefix: String, suffix: String = "")(inside: => Unit): Unit = { 25 | line(prefix) 26 | val originalIndent = currentIndent 27 | currentIndent = currentIndent + indentStep 28 | inside 29 | currentIndent = originalIndent 30 | if (suffix.nonEmpty) { 31 | line(suffix) 32 | } 33 | } 34 | 35 | protected def line(str: String): Unit = { 36 | printStream.print(currentIndent) 37 | printStream.println(str) 38 | } 39 | 40 | protected def line(): Unit = { 41 | printStream.println() 42 | } 43 | 44 | protected def tupleType(size: Int, prefix: String = "T", suffix: String = "", separator: String = ", "): String = 45 | tupleTypeRaw(size, prefix, suffix).mkString(separator) 46 | 47 | protected def tupleAccess(size: Int, varName: String): String = { 48 | tupleAccessRaw(size, varName).mkString(", ") 49 | } 50 | 51 | private def tupleTypeRaw(size: Int, prefix: String = "T", suffix: String = ""): Seq[String] = { 52 | (1 to size).map(i => s"${prefix}${i}${suffix}") 53 | } 54 | 55 | private def tupleAccessRaw(size: Int, varName: String): Seq[String] = { 56 | (1 to size).map(i => s"${varName}._${i}") 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/Named.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | import scala.scalajs.js 4 | 5 | /** This trait lets the user set an ad-hoc name for this instance. Used for debugging and tracing. 6 | * 7 | * Subclasses: [[BaseObservable]], [[Observer]] 8 | */ 9 | trait Named { 10 | 11 | /** This name should identify the instance (observable or observer) uniquely enough for your purposes. 12 | * You can read / write it to simplify debugging. 13 | * Airstream uses this in `debugLog*` methods. In the future, we will expand on this. 14 | * #TODO[Debug] We don't use this to its full potential yet. 15 | */ 16 | protected[this] var maybeDisplayName: js.UndefOr[String] = js.undefined 17 | 18 | /** This is the method that subclasses override to preserve the user's ability to set custom display names. */ 19 | protected def defaultDisplayName: String = s"${getClass.getSimpleName}@${hashCode()}" 20 | 21 | /** Override [[defaultDisplayName]] instead of this, if you need to. */ 22 | final override def toString: String = displayName 23 | 24 | final def displayName: String = maybeDisplayName.getOrElse(defaultDisplayName) 25 | 26 | /** Set the display name for this instance (observable or observer). 27 | * - This method modifies the instance and returns `this`. It does not create a new instance. 28 | * - New name you set will override the previous name, if any. 29 | * This might change in the future. For the sake of sanity, don't call this more than once for the same instance. 30 | * - If display name is set, toString will output it instead of the standard type@hashcode string 31 | */ 32 | def setDisplayName(name: String): this.type = { 33 | maybeDisplayName = name // @TODO[Warn] Maybe we should emit a warning if name was already set 34 | this 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/web/DomEventStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.web 2 | 3 | import com.raquo.airstream.core.EventStream 4 | import com.raquo.airstream.custom.{CustomSource, CustomStreamSource} 5 | import org.scalajs.dom 6 | 7 | import scala.scalajs.js 8 | 9 | object DomEventStream { 10 | 11 | /** 12 | * This stream, when started, registers an event listener on a specific target 13 | * like a DOM element, document, or window, and re-emits all events sent to the listener. 14 | * 15 | * When this stream is stopped, the listener is removed. 16 | * 17 | * @tparam Ev - You need to specify what event type you're expecting. 18 | * The event type depends on the event, i.e. eventKey. Look it up on MDN. 19 | * 20 | * @param eventTarget any DOM event target, e.g. element, document, or window 21 | * @param eventKey DOM event name, e.g. "click", "input", "change" 22 | * @param useCapture See section about "useCapture" in https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener 23 | * 24 | */ 25 | def apply[Ev <: dom.Event]( 26 | eventTarget: dom.EventTarget, 27 | eventKey: String, 28 | useCapture: Boolean = false 29 | ): EventStream[Ev] = { 30 | new CustomStreamSource[Ev]({ 31 | (fireValue, _, _, _) => 32 | // Wrap scala.Function into js.Function only once, 33 | // ensuring that the same function reference is passed to 34 | // removeEventListener as was given to addEventListener. 35 | val eventHandler: js.Function1[Ev, Unit] = fireValue 36 | 37 | CustomSource.Config( 38 | onStart = () => eventTarget.addEventListener(eventKey, eventHandler, useCapture), 39 | onStop = () => eventTarget.removeEventListener(eventKey, eventHandler, useCapture) 40 | ) 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/Source.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | /** A Source is something that can be converted to an [[Observable]]. 4 | * The counterparty to Source is a [[Sink]], something that can be converted to an [[Observer]]. 5 | * 6 | * A Source could be an Observable itself, an EventBus, a Var, or, via implicits, a third party type like Future or ZIO. 7 | * 8 | * The point of using Source instead of Observable in your API is to let the end users 9 | * pass simply `eventBus` instead of `eventBus.events` to a method that requires Source, 10 | * and to achieve that without having an implicit conversion from EventBus to Observable, 11 | * because then you'd also want an implicit conversion from EventBus to Observer, and 12 | * those two would be incompatible (e.g. both Observable and Observer have a filter method). 13 | */ 14 | trait Source[+A] { 15 | def toObservable: Observable[A] 16 | } 17 | 18 | object Source { 19 | 20 | trait EventSource[+A] extends Source[A] { 21 | 22 | override def toObservable: EventStream[A] 23 | } 24 | 25 | trait SignalSource[+A] extends Source[A] { 26 | 27 | override def toObservable: Signal[A] 28 | } 29 | 30 | // #TODO[API] Disabled integrations, let's see if anyone complains. These conversions are unfortunately not smooth enough to be implicit. 31 | 32 | // implicit def futureToEventSource[A](future: Future[A]): EventSource[A] = EventStream.fromFuture(future) 33 | // 34 | // implicit def futureToSignalSource[A](future: Future[A]): SignalSource[Option[A]] = Signal.fromFuture(future) 35 | // 36 | // implicit def jsPromiseToEventSource[A](promise: js.Promise[A]): EventSource[A] = EventStream.fromJsPromise(promise) 37 | // 38 | // implicit def jsPromiseToSignalSource[A](promise: js.Promise[A]): SignalSource[Option[A]] = Signal.fromJsPromise(promise) 39 | 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 4 | ## Workflow 5 | 6 | If you want to add a feature but are not sure how to do this or how it should behave in edge cases, feel free to chat us up in [Discord](https://discord.gg/JTrUxhq7sj). 7 | 8 | When making PRs, please allow Airstream maintainers to make changes to your branch. We might make changes, so make a copy of your branch if you need it. 9 | 10 | 11 | ## Code style 12 | 13 | Please run `sbt scalafmtAll` before submitting the PR. 14 | 15 | This only sets the ground rules, so please try to maintain the general style of the codebase. 16 | 17 | 18 | ## Tests 19 | 20 | Please run `sbt +test` locally before submitting the PR. 21 | 22 | Note that existing tests print this compiler warning in Scala 3: 23 | - [E029] Pattern Match Exhaustivity Warning: .../src/test/scala-3/com/raquo/airstream/split/SplitMatchOneSpec.scala 24 | 25 | This is expected. Ideally I would assert that this warning exists instead of printing it, but I don't think that's possible. I don't want to hide such warnings wholesale, but suggestions for improvement are welcome. 26 | 27 | 28 | ## N-Generators 29 | 30 | Airstream offers several types of methods like `combineWith` and `mapN` in varying arity. These live in packages called `generated`, in implicit classes that are generated at compile time by generators located in the `project` folder. 31 | 32 | To apply and execute your changes to the generators, run `reload; compile` in sbt. We commit all generated files to git because invisible code is annoying to figure out, and to help with source maps. 33 | 34 | 35 | # Documentation 36 | 37 | README.md needs to be updated before the changes can be merged into `master`. I usually do this after the feature is done. 38 | 39 | We used to publish itemized changes in CHANGELOG.md, but we switched to publishing release blog posts at [laminar.dev](https://laminar.dev). 40 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/debug/DebuggerSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.debug 2 | 3 | import com.raquo.airstream.common.SingleParentSignal 4 | import com.raquo.airstream.core.{AirstreamError, Protected, Signal, Transaction} 5 | import com.raquo.airstream.core.AirstreamError.DebugError 6 | 7 | import scala.util.Try 8 | 9 | /** See [[DebuggableObservable]] and [[DebuggableSignal]] for user-facing debug methods */ 10 | class DebuggerSignal[A]( 11 | override protected[this] val parent: Signal[A], 12 | override protected val debugger: Debugger[A] 13 | ) extends SingleParentSignal[A, A] with DebuggerObservable[A] { 14 | 15 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 16 | 17 | override protected def defaultDisplayName: String = DebuggerObservable.defaultDisplayName(parent) 18 | 19 | override protected def currentValueFromParent(): Try[A] = { 20 | val parentValue = parent.tryNow() 21 | try { 22 | debugger.onEvalFromParent(parentValue) 23 | } catch { 24 | case err: Throwable => 25 | val maybeCause = parentValue.toEither.left.toOption 26 | AirstreamError.sendUnhandledError(DebugError(err, cause = maybeCause)) 27 | } 28 | parentValue 29 | } 30 | 31 | override protected[this] def fireTry(nextValue: Try[A], transaction: Transaction): Unit = { 32 | debugFireTry(nextValue) 33 | super.fireTry(nextValue, transaction) 34 | } 35 | 36 | override protected[this] def onStart(): Unit = { 37 | super.onStart() 38 | debugOnStart() 39 | debugFireTry(tryNow()) 40 | } 41 | 42 | override protected[this] def onStop(): Unit = { 43 | super.onStop() 44 | debugOnStop() 45 | } 46 | 47 | override protected def onTry(nextParentValue: Try[A], transaction: Transaction): Unit = { 48 | super.onTry(nextParentValue, transaction) 49 | fireTry(nextParentValue, transaction) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/distinct/DistinctSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.distinct 2 | 3 | import com.raquo.airstream.common.SingleParentSignal 4 | import com.raquo.airstream.core.{Protected, Signal, Transaction} 5 | 6 | import scala.util.Try 7 | 8 | /** Emits only values that are distinct from the last emitted value, according to isSame function */ 9 | class DistinctSignal[A]( 10 | override protected[this] val parent: Signal[A], 11 | isSame: (Try[A], Try[A]) => Boolean, 12 | resetOnStop: Boolean 13 | ) extends SingleParentSignal[A, A] { 14 | 15 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 16 | 17 | override protected def onTry(nextParentValue: Try[A], transaction: Transaction): Unit = { 18 | super.onTry(nextParentValue, transaction) 19 | if (!isSame(tryNow(), nextParentValue)) { 20 | fireTry(nextParentValue, transaction) 21 | } 22 | } 23 | 24 | override protected def currentValueFromParent(): Try[A] = parent.tryNow() 25 | 26 | /** Special implementation to add the distinct-ness filter */ 27 | override protected def updateCurrentValueFromParent( 28 | nextValue: Try[A], 29 | nextParentLastUpdateId: Int 30 | ): Unit = { 31 | // #TODO[Integrity] should I also check for lastUpdateId in addition to isSame? 32 | // - if isSame, then it doesn't matter if the parent emitted, right? No event anyway. 33 | // - if not isSame, then I don't think it's possible that the parent has NOT emitted, 34 | // unless you're using some super weird isSame function where a != a. 35 | // #Note We check this signal's standard distinction condition with !isSame instead of `==` 36 | // because isSame might be something incompatible, e.g. reference equality 37 | if (resetOnStop || !isSame(nextValue, tryNow())) { 38 | super.updateCurrentValueFromParent(nextValue, nextParentLastUpdateId) // #nc check this????? 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/timing/DebounceStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.timing 2 | 3 | import com.raquo.airstream.common.{InternalTryObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{EventStream, Transaction} 5 | 6 | import scala.scalajs.js 7 | import scala.scalajs.js.timers.SetTimeoutHandle 8 | import scala.util.Try 9 | 10 | // @TODO[Test] Verify debounce 11 | 12 | /** This stream emits the last event emitted by `parent`, but only after `intervalMs` has elapsed 13 | * since `parent` emitted the previous event. 14 | * 15 | * Essentially, this stream emits the parent's last event, but only once the parent stops emitting 16 | * events for `intervalMs`. 17 | * 18 | * When stopped, this stream "forgets" about any pending events. 19 | * 20 | * See also [[ThrottleStream]] 21 | */ 22 | class DebounceStream[A]( 23 | override protected[this] val parent: EventStream[A], 24 | intervalMs: Int 25 | ) extends SingleParentStream[A, A] with InternalTryObserver[A] { 26 | 27 | private[this] var maybeLastTimeoutHandle: js.UndefOr[SetTimeoutHandle] = js.undefined 28 | 29 | override protected val topoRank: Int = 1 30 | 31 | /** Every time [[parent]] emits an event, we clear the previous timer and set a new one. 32 | * This stream only emits when the parent has stopped emitting for [[intervalMs]] ms. 33 | */ 34 | override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 35 | maybeLastTimeoutHandle.foreach(js.timers.clearTimeout) 36 | maybeLastTimeoutHandle = js.timers.setTimeout(intervalMs.toDouble) { 37 | // println(s"> init trx from DebounceEventStream.onTry($nextValue)") 38 | Transaction(fireTry(nextValue, _)) 39 | } 40 | } 41 | 42 | override protected[this] def onStop(): Unit = { 43 | maybeLastTimeoutHandle.foreach(js.timers.clearTimeout) 44 | maybeLastTimeoutHandle = js.undefined 45 | super.onStop() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/web/WebStorageBuilder.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.web 2 | 3 | import com.raquo.airstream.ownership.Owner 4 | import org.scalajs.dom 5 | 6 | import scala.util.{Success, Try} 7 | 8 | /** This intermediate step is usually created via 9 | * [[WebStorageVar]] `object` methods. 10 | */ 11 | class WebStorageBuilder( 12 | maybeStorage: () => Option[dom.Storage], 13 | key: String, 14 | syncOwner: Option[Owner] 15 | ) { 16 | 17 | def text(default: => String): WebStorageVar[String] = { 18 | withCodec( 19 | encode = identity, 20 | decode = Success(_), 21 | default = Success(default), 22 | syncDistinctByFn = _ == _ 23 | ) 24 | } 25 | 26 | def bool(default: => Boolean): WebStorageVar[Boolean] = { 27 | withCodec[Boolean]( 28 | encode = _.toString, 29 | decode = str => Try(str.toBoolean), 30 | default = Success(default), 31 | syncDistinctByFn = _ == _ 32 | ) 33 | } 34 | 35 | def int(default: => Int): WebStorageVar[Int] = { 36 | withCodec[Int]( 37 | encode = _.toString, 38 | decode = str => Try(str.toInt), 39 | default = Success(default), 40 | syncDistinctByFn = _ == _ 41 | ) 42 | } 43 | 44 | /** 45 | * @param encode Must not throw! 46 | * @param decode Must not throw! 47 | * @param default If key is not found in storage either initially 48 | * or at any future point, this value will be used instead. 49 | */ 50 | def withCodec[A]( 51 | encode: A => String, 52 | decode: String => Try[A], 53 | default: => Try[A], 54 | syncDistinctByFn: (A, A) => Boolean = (_: A) == (_: A), 55 | ): WebStorageVar[A] = { 56 | val storageVar = new WebStorageVar[A]( 57 | maybeStorage, key, encode, decode, default, 58 | syncDistinctByFn 59 | ) 60 | syncOwner.foreach(storageVar.syncFromExternalUpdates(_)) 61 | storageVar 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/timing/JsPromiseStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.timing 2 | 3 | import com.raquo.airstream.core.{Transaction, WritableStream} 4 | 5 | import scala.scalajs.js 6 | import scala.scalajs.js.| 7 | 8 | /** This stream emits a value that the promise resolves with, even if the promise 9 | * was already resolved. 10 | * 11 | * This stream emits only once. If you want to remember the value, Use [[JsPromiseSignal]] instead. 12 | * 13 | * @param promise Note: guarded against failures 14 | */ 15 | class JsPromiseStream[A](promise: js.Promise[A], emitOnce: Boolean) extends WritableStream[A] { 16 | 17 | override protected val topoRank: Int = 1 18 | 19 | // #Note: It is not possible to synchronously check if a Javascript promise has been resolved or not, 20 | // so we can't base our logic on whether the promise was resolved or not at a particular time. 21 | 22 | private var shouldSubscribe: Boolean = true 23 | 24 | private var isPending: Boolean = false 25 | 26 | override protected def onWillStart(): Unit = { 27 | if (shouldSubscribe && !isPending) { 28 | if (emitOnce) { 29 | shouldSubscribe = false 30 | } 31 | isPending = true 32 | promise.`then`[Unit]( 33 | (nextValue: A) => { 34 | isPending = false 35 | // println(s"> init trx from FutureEventStream.init($nextValue)") 36 | Transaction(fireValue(nextValue, _)) 37 | (): Unit | js.Thenable[Unit] 38 | }, 39 | js.defined { (rawException: Any) => 40 | isPending = false 41 | val nextError = rawException match { 42 | case th: Throwable => th 43 | case _ => js.JavaScriptException(rawException) 44 | } 45 | // println(s"> init trx from JsPromiseEventStream.init($nextError)") 46 | Transaction(fireError(nextError, _)) 47 | (): Unit | js.Thenable[Unit] 48 | } 49 | ) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/InternalParentObserver.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.{InternalObserver, Observable, Transaction} 4 | 5 | import scala.util.Try 6 | 7 | trait InternalParentObserver[A] extends InternalObserver[A] { 8 | 9 | protected[this] val parent: Observable[A] 10 | 11 | def addToParent(shouldCallMaybeWillStart: Boolean): Unit = { 12 | parent.addInternalObserver(this, shouldCallMaybeWillStart) 13 | } 14 | 15 | def removeFromParent(): Unit = { 16 | parent.removeInternalObserver(observer = this) 17 | } 18 | } 19 | 20 | object InternalParentObserver { 21 | 22 | def apply[A]( 23 | parent: Observable[A], 24 | onNext: (A, Transaction) => Unit, 25 | onError: (Throwable, Transaction) => Unit 26 | ): InternalParentObserver[A] = { 27 | val parentParam = parent 28 | val onNextParam = onNext 29 | val onErrorParam = onError 30 | new InternalParentObserver[A] with InternalNextErrorObserver[A] { 31 | 32 | override protected[this] val parent: Observable[A] = parentParam 33 | 34 | final override protected def onNext(nextValue: A, transaction: Transaction): Unit = { 35 | onNextParam(nextValue, transaction) 36 | } 37 | 38 | final override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { 39 | onErrorParam(nextError, transaction) 40 | } 41 | } 42 | } 43 | 44 | def fromTry[A]( 45 | parent: Observable[A], 46 | onTry: (Try[A], Transaction) => Unit 47 | ): InternalParentObserver[A] = { 48 | val parentParam = parent 49 | val onTryParam = onTry 50 | new InternalParentObserver[A] with InternalTryObserver[A] { 51 | 52 | override protected[this] val parent: Observable[A] = parentParam 53 | 54 | final override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 55 | onTryParam(nextValue, transaction) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/debug/DebuggerObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.debug 2 | 3 | import com.raquo.airstream.common.InternalTryObserver 4 | import com.raquo.airstream.core.{AirstreamError, Observable} 5 | import com.raquo.airstream.core.AirstreamError.DebugError 6 | 7 | import scala.util.Try 8 | 9 | /** See [[DebuggableObservable]] and [[DebuggableSignal]] for user-facing debug methods */ 10 | trait DebuggerObservable[A] extends InternalTryObserver[A] { 11 | 12 | protected val debugger: Debugger[A] 13 | 14 | protected[this] def debugFireTry(nextValue: Try[A]): Unit = { 15 | try { 16 | debugger.onFire(nextValue) 17 | } catch { 18 | case err: Throwable => 19 | val maybeCause = nextValue.toEither.left.toOption 20 | AirstreamError.sendUnhandledError(DebugError(err, cause = maybeCause)) 21 | } 22 | } 23 | 24 | protected[this] def debugOnStart(): Unit = { 25 | try { 26 | debugger.onStart() 27 | } catch { 28 | case err: Throwable => AirstreamError.sendUnhandledError(DebugError(err, cause = None)) 29 | } 30 | } 31 | 32 | protected[this] def debugOnStop(): Unit = { 33 | try { 34 | debugger.onStop() 35 | } catch { 36 | case err: Throwable => AirstreamError.sendUnhandledError(DebugError(err, cause = None)) 37 | } 38 | } 39 | } 40 | 41 | object DebuggerObservable { 42 | 43 | def defaultDisplayName[A](parent: Observable[A]): String = { 44 | parent match { 45 | case _: DebuggerObservable[_] => 46 | // #TODO[UX] This could be confusing. But the alternative (|Debug|Debug|Debug names) is annoying. 47 | // When chaining multiple debug observables, they will inherit the parent's displayName 48 | parent.displayName 49 | case _ => 50 | // We need to indicate that this isn't the original observable, but a debugged one, 51 | // otherwise debugging could get really confusing 52 | s"${parent.displayName}|Debug" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/LazyStrictSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.{Protected, Signal} 4 | import com.raquo.airstream.misc.MapSignal 5 | 6 | import scala.util.Try 7 | 8 | /** #TODO[Naming,Org] Messy 9 | * 10 | * This signal offers the API of a [[StrictSignal]] but is actually 11 | * lazy. All it does is let you PULL the signal's current value. 12 | * This mostly works fine if your signal does not depend on any streams. 13 | * 14 | * We use this signal internally for derived Var use cases where we know 15 | * that it should work fine. we may change the naming and structure of 16 | * this class when we implement settle on a long term strategy for 17 | * peekNow / pullNow https://github.com/raquo/Laminar/issues/130 18 | */ 19 | class LazyStrictSignal[I, O]( 20 | parentSignal: Signal[I], 21 | zoomIn: I => O, 22 | parentDisplayName: => String, 23 | displayNameSuffix: String 24 | ) extends MapSignal[I, O](parentSignal, project = zoomIn, recover = None) with StrictSignal[O] { self => 25 | 26 | override protected def defaultDisplayName: String = parentDisplayName + displayNameSuffix + s"@${hashCode()}" 27 | 28 | override def tryNow(): Try[O] = { 29 | val newParentLastUpdateId = Protected.lastUpdateId(parentSignal) 30 | // #TODO This comparison only works when parentSignal is started or strict. 31 | // - e.g. it does not help us in `split`, it only helps us with lazyZoom. 32 | if (newParentLastUpdateId != _parentLastUpdateId) { 33 | // This branch can only run if !isStarted 34 | val nextValue = currentValueFromParent() 35 | updateCurrentValueFromParent(nextValue, newParentLastUpdateId) 36 | nextValue 37 | } else { 38 | super.tryNow() 39 | } 40 | } 41 | 42 | override protected[state] def updateCurrentValueFromParent(nextValue: Try[O], nextParentLastUpdateId: Int): Unit = 43 | super.updateCurrentValueFromParent(nextValue, nextParentLastUpdateId) 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/OptionSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.Signal 4 | import com.raquo.airstream.split.DuplicateKeysConfig 5 | 6 | class OptionSignal[A](val signal: Signal[Option[A]]) extends AnyVal { 7 | 8 | /** This `.split`-s a Signal of an Option by the Option's `isDefined` property. 9 | * If you want a different key, use the .split operator directly. 10 | * 11 | * @param project - (initialInput, signalOfInput) => output 12 | * `project` is called whenever the parent signal switches from `None` to `Some()`. 13 | * `signalOfInput` starts with `initialInput` value, and updates when 14 | * the parent signal updates from `Some(a)` to `Some(b)`. 15 | * @param ifEmpty - returned if Option is empty. Evaluated whenever the parent signal 16 | * switches from `Some(a)` to `None`, or when the parent signal 17 | * starts with a `None`. `ifEmpty` is NOT re-evaluated when the 18 | * parent signal emits `None` if its value is already `None`. 19 | */ 20 | def splitOption[B]( 21 | project: (A, Signal[A]) => B, 22 | ifEmpty: => B 23 | ): Signal[B] = { 24 | // Note: We never have duplicate keys here, so we can use 25 | // DuplicateKeysConfig.noWarnings to improve performance 26 | signal 27 | .distinctByFn((prev, next) => prev.isEmpty && next.isEmpty) // Ignore consecutive `None` events 28 | .split( 29 | key = _ => (), 30 | duplicateKeys = DuplicateKeysConfig.noWarnings 31 | ) { (_, initial, signal) => 32 | project(initial, signal) 33 | } 34 | .map(_.getOrElse(ifEmpty)) 35 | } 36 | 37 | def splitOption[B]( 38 | project: (A, Signal[A]) => B 39 | ): Signal[Option[B]] = { 40 | splitOption( 41 | (initial, someSignal) => Some(project(initial, someSignal)), 42 | ifEmpty = None 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/StatusSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.Signal 4 | import com.raquo.airstream.split.SplittableOneSignal 5 | import com.raquo.airstream.status.{Pending, Resolved, Status} 6 | 7 | class StatusSignal[In, Out](val signal: Signal[Status[In, Out]]) extends AnyVal { 8 | 9 | /** This `.split`-s a signal of Statuses by their type (resolved vs pending). 10 | * If you want a different key, use the .splitOne operator directly. 11 | * 12 | * @param resolved (initialResolved, signalOfResolvedValues) => output 13 | * `resolved` is called whenever parent signal switches from `Pending` to `Resolved`. 14 | * `signalOfResolvedValues` starts with `initialResolved` value, and updates when 15 | * the parent signal emits a new `Resolved` consecutively after another `Resolved`. 16 | * @param pending (initialPending, signalOfPendingValues) => output 17 | * `pending` is called whenever parent signal switches from `Resolved` to `Pending`, 18 | * or when the signal's initial value is evaluated (and it's `Pending`, as is typical) 19 | * `signalOfPendingValues` starts with `initialPending` value, and updates when 20 | * the parent signal emits a new `Resolved` consecutively after another `Resolved`. 21 | * This happens when the signal emits inputs faster than the outputs are resolved. 22 | */ 23 | def splitStatus[A]( 24 | resolved: (Resolved[In, Out], Signal[Resolved[In, Out]]) => A, 25 | pending: (Pending[In], Signal[Pending[In]]) => A 26 | ): Signal[A] = { 27 | new SplittableOneSignal(signal).splitOne( 28 | key = _.isResolved 29 | ) { (_, initial, signal) => 30 | initial.fold( 31 | resolved(_, signal.asInstanceOf[Signal[Resolved[In, Out]]]), 32 | pending(_, signal.asInstanceOf[Signal[Pending[In]]]) 33 | ) 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/SignalFromStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.SingleParentSignal 4 | import com.raquo.airstream.core.{EventStream, Protected, Transaction} 5 | 6 | import scala.scalajs.js 7 | import scala.util.Try 8 | 9 | class SignalFromStream[A]( 10 | override protected[this] val parent: EventStream[A], 11 | pullInitialValue: => Try[A], 12 | cacheInitialValue: Boolean 13 | ) extends SingleParentSignal[A, A] { 14 | 15 | private var hasEmittedEvents = false 16 | 17 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 18 | 19 | // #Note: this overrides the default implementation 20 | override protected def onWillStart(): Unit = { 21 | Protected.maybeWillStart(parent) 22 | maybeCurrentValueFromParent.foreach(setCurrentValue) 23 | } 24 | 25 | override protected def currentValueFromParent(): Try[A] = { 26 | maybeCurrentValueFromParent.getOrElse(tryNow()) 27 | } 28 | 29 | private def maybeCurrentValueFromParent: js.UndefOr[Try[A]] = { 30 | // #Note See also SplitChildSignal and CustomSignalSource for similar logic 31 | // #Note This can be called from inside tryNow(), so make sure to avoid an infinite loop 32 | if (maybeLastSeenCurrentValue.isEmpty) { 33 | // Signal has no current value – first time this is called 34 | pullInitialValue 35 | } else if (!hasEmittedEvents && !cacheInitialValue) { 36 | // Signal has current value, has not emitted yet, and we're pulling a fresh one 37 | // Essentially, we keep its value in sync with the `pullInitialValue` expression 38 | // on every restart. #TODO[API] Not sure if this is a good default, to be honest. 39 | pullInitialValue 40 | } else { 41 | js.undefined 42 | } 43 | } 44 | 45 | override protected def onTry(nextParentValue: Try[A], transaction: Transaction): Unit = { 46 | hasEmittedEvents = true 47 | super.onTry(nextParentValue, transaction) 48 | fireTry(nextParentValue, transaction) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/OptionStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{EventStream, Signal} 4 | 5 | /** See also: [[OptionObservable]] */ 6 | class OptionStream[A](val stream: EventStream[Option[A]]) extends AnyVal { 7 | 8 | /** Emit `x` if parent stream emits `Some(x)`, do nothing otherwise */ 9 | def collectSome: EventStream[A] = stream.collect { case Some(ev) => ev } 10 | 11 | /** Emit `pf(x)` if parent stream emits `Some(x)` and `pf` is defined for `x`, do nothing otherwise */ 12 | def collectSome[B](pf: PartialFunction[A, B]): EventStream[B] = { 13 | stream.collectOpt(_.collect(pf)) 14 | } 15 | 16 | /** This `.split`-s a Stream of an Option by the Option's `isDefined` property. 17 | * If you want a different key, use the .split operator directly. 18 | * 19 | * @param project - (initialInput, signalOfInput) => output 20 | * `project` is called whenever the parent signal switches from `None` to `Some()`. 21 | * `signalOfInput` starts with `initialInput` value, and updates when 22 | * the parent stream updates from `Some(a)` to `Some(b)`. 23 | * @param ifEmpty - returned if Option is empty, or if the parent stream has not emitted 24 | * any events yet. Re-evaluated whenever the parent `stream` switches from 25 | * `Some(a)` to `None`. `ifEmpty` is NOT re-evaluated when the parent 26 | * stream emits `None` if the last event it emitted was also a `None`. 27 | */ 28 | def splitOption[B]( 29 | project: (A, Signal[A]) => B, 30 | ifEmpty: => B 31 | ): Signal[B] = { 32 | new OptionSignal(stream.startWith(None, cacheInitialValue = true)).splitOption(project, ifEmpty) 33 | } 34 | 35 | def splitOption[B]( 36 | project: (A, Signal[A]) => B 37 | ): Signal[Option[B]] = { 38 | splitOption( 39 | (initial, someSignal) => Some(project(initial, someSignal)), 40 | ifEmpty = None 41 | ) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/OptionObservable.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{BaseObservable, EventStream, Observable, Signal} 4 | 5 | /** See also: [[OptionStream]] */ 6 | class OptionObservable[A, Self[+_] <: Observable[_]](val observable: BaseObservable[Self, Option[A]]) extends AnyVal { 7 | 8 | /** Maps the value in Some(x) */ 9 | def mapSome[B](project: A => B): Self[Option[B]] = { 10 | observable.map(_.map(project)) 11 | } 12 | 13 | /** Filters the value in Some(x) */ 14 | def mapFilterSome(passes: A => Boolean): Self[Option[A]] = { 15 | observable.map(_.filter(passes)) 16 | } 17 | 18 | /** Maps the value in Some(x), and the None value, to a common type. */ 19 | def foldOption[B](ifEmpty: => B)(some: A => B): Self[B] = { 20 | observable.map(_.fold(ifEmpty)(some)) 21 | } 22 | 23 | /** Maps Option[A] to Either[L, A] - you need to provide the L. */ 24 | def mapToRight[L](left: => L): Self[Either[L, A]] = { 25 | observable.map(_.toRight(left)) 26 | } 27 | 28 | /** Maps Option[A] to Either[A, R] - you need to provide the R. */ 29 | def mapToLeft[R](right: => R): Self[Either[A, R]] = { 30 | observable.map(_.toLeft(right)) 31 | } 32 | 33 | def splitOption[B]( 34 | project: (A, Signal[A]) => B, 35 | ifEmpty: => B 36 | ): Signal[B] = { 37 | observable match { 38 | case stream: EventStream[Option[A @unchecked] @unchecked] => 39 | new OptionStream(stream).splitOption(project, ifEmpty) 40 | case signal: Signal[Option[A @unchecked] @unchecked] => 41 | new OptionSignal(signal).splitOption(project, ifEmpty) 42 | } 43 | } 44 | 45 | def splitOption[B]( 46 | project: (A, Signal[A]) => B 47 | ): Signal[Option[B]] = { 48 | observable match { 49 | case stream: EventStream[Option[A @unchecked] @unchecked] => 50 | new OptionStream(stream).splitOption(project) 51 | case signal: Signal[Option[A @unchecked] @unchecked] => 52 | new OptionSignal(signal).splitOption(project) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/combine/CombineSeqStreamSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.combine 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.core.{EventStream, Observer} 5 | import com.raquo.airstream.eventbus.EventBus 6 | import com.raquo.airstream.fixtures.{Effect, TestableOwner} 7 | 8 | import scala.collection.mutable 9 | 10 | class CombineSeqStreamSpec extends UnitSpec { 11 | 12 | it("should work as expected") { 13 | 14 | implicit val testOwner: TestableOwner = new TestableOwner 15 | 16 | val numStreams = 10 17 | 18 | val buses = (1 to numStreams).map(_ => new EventBus[Int]) 19 | val seqStream = EventStream.combineSeq(buses.map(_.events)) 20 | 21 | val effects = mutable.Buffer[Effect[Seq[Int]]]() 22 | 23 | val observer = Observer[Seq[Int]](effects += Effect("combined", _)) 24 | 25 | // -- 26 | 27 | effects.shouldBeEmpty 28 | 29 | // -- 30 | 31 | val subscription = seqStream.addObserver(observer) 32 | 33 | // -- 34 | 35 | effects.shouldBeEmpty 36 | 37 | // -- 38 | 39 | val numIterations = 10 40 | for (iteration <- 1 to numIterations) { 41 | for (streamToEmitFrom <- buses.indices) { 42 | effects.clear() 43 | buses(streamToEmitFrom).writer.onNext(iteration) 44 | if (iteration == 1) { 45 | if (streamToEmitFrom == numStreams - 1) { 46 | effects.toList shouldBe List( 47 | Effect("combined", 48 | buses.indices.map(_ => iteration) 49 | ) 50 | ) 51 | } else { 52 | effects.shouldBeEmpty 53 | } 54 | } else { 55 | effects.toList shouldBe (List( 56 | Effect("combined", 57 | buses.indices.map { index => 58 | if (index > streamToEmitFrom) { 59 | iteration - 1 60 | } else { 61 | iteration 62 | } 63 | } 64 | ) 65 | )) 66 | } 67 | } 68 | } 69 | subscription.kill() 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/timing/DelayStreamSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.timing 2 | 3 | import com.raquo.airstream.AsyncUnitSpec 4 | import com.raquo.airstream.core.Observer 5 | import com.raquo.airstream.eventbus.EventBus 6 | import com.raquo.airstream.fixtures.{Effect, TestableOwner} 7 | import org.scalatest.BeforeAndAfter 8 | 9 | import scala.collection.mutable 10 | 11 | class DelayStreamSpec extends AsyncUnitSpec with BeforeAndAfter { 12 | 13 | implicit val owner: TestableOwner = new TestableOwner 14 | 15 | val effects = mutable.Buffer[Effect[Int]]() 16 | 17 | val obs1 = Observer[Int](effects += Effect("obs1", _)) 18 | 19 | before { 20 | owner.killSubscriptions() 21 | effects.clear() 22 | } 23 | 24 | it("events are delayed, and purged on stop") { 25 | val bus = new EventBus[Int] 26 | val stream = bus.events.delay(30) 27 | 28 | val sub = stream.addObserver(obs1) 29 | 30 | delay { 31 | effects shouldBe mutable.Buffer() 32 | 33 | // -- 34 | 35 | bus.writer.onNext(1) 36 | 37 | effects shouldBe mutable.Buffer() 38 | 39 | }.flatMap[Unit] { _ => 40 | delay(30) { 41 | effects shouldBe mutable.Buffer(Effect("obs1", 1)) 42 | effects.clear() 43 | 44 | bus.writer.onNext(2) 45 | bus.writer.onNext(3) 46 | 47 | assert(effects.isEmpty) 48 | () 49 | } 50 | }.flatMap[Unit] { _ => 51 | delay(30) { 52 | effects shouldBe mutable.Buffer(Effect("obs1", 2), Effect("obs1", 3)) 53 | effects.clear() 54 | 55 | bus.writer.onNext(4) 56 | bus.writer.onNext(5) 57 | 58 | sub.kill() // this kills pending events even if we immediately restart 59 | 60 | assert(effects.isEmpty) 61 | 62 | stream.addObserver(obs1) 63 | 64 | bus.writer.onNext(6) 65 | } 66 | }.flatMap { _ => 67 | delay(40) { // a bit extra margin for the last check just to be sure that we caught any events 68 | effects shouldBe mutable.Buffer(Effect("obs1", 6)) 69 | effects.clear() 70 | assert(true) 71 | } 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/timing/JsPromiseSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.timing 2 | 3 | import com.raquo.airstream.core.{Transaction, WritableSignal} 4 | 5 | import scala.scalajs.js 6 | import scala.util.{Failure, Success, Try} 7 | 8 | class JsPromiseSignal[A](promise: js.Promise[A]) extends WritableSignal[Option[A]] { 9 | 10 | override protected val topoRank: Int = 1 11 | 12 | private var promiseSubscribed: Boolean = false 13 | 14 | // #Note: It is not possible to synchronously get a Javascript promise's value, 15 | // or even to check if it has been resolved, so we have to start this signal with None 16 | // to avoid creating an infinite loop with our currentValueFromParent implementation. 17 | setCurrentValue(Success(None)) 18 | 19 | // #Note: We can't pull data from JS Promise on demand, this async access below is the best we can do. 20 | override protected def currentValueFromParent(): Try[Option[A]] = tryNow() // noop 21 | 22 | override protected def onWillStart(): Unit = { 23 | if (!promiseSubscribed) { 24 | promiseSubscribed = true 25 | promise.`then`[Unit]( 26 | (nextValue: A) => { 27 | onPromiseResolved(Success(nextValue)) 28 | }, 29 | js.defined { (rawException: Any) => 30 | val nextError = rawException match { 31 | case th: Throwable => th 32 | case _ => js.JavaScriptException(rawException) 33 | } 34 | onPromiseResolved(Failure(nextError)) 35 | } 36 | ) 37 | } 38 | } 39 | 40 | private def onPromiseResolved(nextPromiseValue: Try[A]): Unit = { 41 | // #TODO[Doc] Document this about onWillStart 42 | // #Note Normally onWillStart must not create transactions / emit values, but this is ok here 43 | // because this callback is always called asynchronously, so any value will be emitted from here 44 | // long after the onWillStart / onStart chain has finished. 45 | // #Note fireTry sets current value even if the signal has no observers 46 | val nextValue = nextPromiseValue.map(Some(_)) 47 | // println(s"> init trx from FutureSignal($value)") 48 | Transaction(fireTry(nextValue, _)) // #Note[onStart,trx,async] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/combine/CombineStreamN.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.combine 2 | 3 | import com.raquo.airstream.common.{InternalParentObserver, MultiParentStream} 4 | import com.raquo.airstream.core.{EventStream, Observable, Protected} 5 | import com.raquo.ew.JsArray 6 | 7 | import scala.scalajs.js 8 | import scala.util.Try 9 | 10 | /** 11 | * @param parentStreams Never update this array - this stream owns it. 12 | * @param combinator Must not throw! Must be pure. 13 | */ 14 | class CombineStreamN[A, Out]( 15 | parentStreams: JsArray[EventStream[A]], 16 | combinator: JsArray[A] => Out 17 | ) extends MultiParentStream[A, Out] with CombineObservable[Out] { 18 | 19 | // @TODO[API] Maybe this should throw if parents.isEmpty 20 | 21 | override protected[this] val parents: JsArray[Observable[A]] = { 22 | // #Note this is safe as long as we don't put non-streams into this JsArray. 23 | parentStreams.asInstanceOf[JsArray[Observable[A]]] 24 | } 25 | 26 | override protected val topoRank: Int = Protected.maxTopoRank(parents) + 1 27 | 28 | private[this] val maybeLastParentValues: JsArray[js.UndefOr[Try[A]]] = parents.map(_ => js.undefined) 29 | 30 | override protected[this] val parentObservers: JsArray[InternalParentObserver[_]] = { 31 | parents.mapWithIndex { (parent, ix) => 32 | InternalParentObserver.fromTry[A]( 33 | parent, 34 | (nextParentValue, trx) => { 35 | maybeLastParentValues.update(ix, nextParentValue) 36 | if (inputsReady) { 37 | onInputsReady(trx) 38 | } 39 | } 40 | ) 41 | } 42 | } 43 | 44 | override protected[this] def inputsReady: Boolean = { 45 | var allReady: Boolean = true 46 | maybeLastParentValues.forEach { lastValue => 47 | if (lastValue.isEmpty) { 48 | allReady = false 49 | } 50 | } 51 | allReady 52 | } 53 | 54 | override protected[this] def combinedValue: Try[Out] = { 55 | // #Note don't call this unless you have first verified that 56 | // inputs are ready, otherwise this asInstanceOf will not be safe. 57 | CombineObservable.jsArrayCombinator(maybeLastParentValues.asInstanceOf[JsArray[Try[A]]], combinator) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/ShouldSyntax.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream 2 | 3 | import org.scalactic.{source, Prettifier} 4 | import org.scalatest.Assertion 5 | import org.scalatest.enablers.Emptiness 6 | import org.scalatest.matchers.should 7 | 8 | // I don't want to use the full variety of ScalaTest "should" matchers. 9 | // Those that we actually need should be defined as simple methods here. 10 | 11 | class ShouldSyntax[A](val actual: A) extends AnyVal { 12 | 13 | def shouldBe( 14 | expected: scala.Any 15 | )(implicit 16 | pos: source.Position, 17 | prettifier: Prettifier 18 | ): Assertion = { 19 | ShouldSyntax.shouldBe(actual, expected)(pos, prettifier) 20 | } 21 | 22 | def shouldBeEmpty(implicit 23 | pos: source.Position, 24 | prettifier: Prettifier, 25 | emptiness: Emptiness[A] 26 | ): Assertion = { 27 | ShouldSyntax.shouldBeEmpty(actual)(pos, prettifier, emptiness) 28 | } 29 | 30 | def shouldNotBe( 31 | expected: scala.Any 32 | )(implicit 33 | pos: source.Position, 34 | prettifier: Prettifier 35 | ): Assertion = { 36 | ShouldSyntax.shouldNotBe(actual, expected)(pos, prettifier) 37 | } 38 | } 39 | 40 | object ShouldSyntax extends should.Matchers { 41 | 42 | // #Note ScalaTest generates different code for Scala 2 and Scala 3. 43 | // In particular, it does not emit convertToAnyShouldWrapper in Scala 3. 44 | // I don't care enough to figure out what or why. 45 | // If you do, see SKIP-DOTTY-START and SKIP-DOTTY-STOP in ScalaTest code and go from there. 46 | 47 | def shouldBe[A]( 48 | actual: A, 49 | expected: scala.Any 50 | )(implicit 51 | pos: source.Position, 52 | prettifier: Prettifier 53 | ): Assertion = { 54 | actual shouldBe expected 55 | } 56 | 57 | def shouldBeEmpty[A]( 58 | actual: A 59 | )(implicit 60 | pos: source.Position, 61 | prettifier: Prettifier, 62 | emptiness: Emptiness[A] 63 | ): Assertion = { 64 | actual shouldBe empty 65 | } 66 | 67 | def shouldNotBe[A]( 68 | actual: A, 69 | expected: scala.Any 70 | )(implicit 71 | pos: source.Position, 72 | prettifier: Prettifier 73 | ): Assertion = { 74 | actual shouldNot be(expected) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/WritableStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | import com.raquo.airstream.core.AirstreamError.ObserverError 4 | 5 | import scala.util.Try 6 | 7 | trait WritableStream[A] extends EventStream[A] with WritableObservable[A] { 8 | 9 | override protected[this] def fireValue(nextValue: A, transaction: Transaction): Unit = { 10 | // println(s"$this > FIRE > $nextValue") 11 | 12 | // === CAUTION === 13 | // The following logic must match Signal's fireTry! It is separated here for performance. 14 | 15 | isSafeToRemoveObserver = false 16 | 17 | externalObservers.foreach { observer => 18 | try { 19 | observer.onNext(nextValue) 20 | } catch { 21 | case err: Throwable => AirstreamError.sendUnhandledError(ObserverError(err)) 22 | } 23 | } 24 | 25 | internalObservers.foreach { observer => 26 | InternalObserver.onNext(observer, nextValue, transaction) 27 | } 28 | 29 | isSafeToRemoveObserver = true 30 | 31 | maybePendingObserverRemovals.foreach { pendingObserverRemovals => 32 | pendingObserverRemovals.forEach(remove => remove()) 33 | pendingObserverRemovals.length = 0 34 | } 35 | } 36 | 37 | override protected[this] def fireError(nextError: Throwable, transaction: Transaction): Unit = { 38 | // println(s"$this > FIRE > $nextError") 39 | 40 | // === CAUTION === 41 | // The following logic must match Signal's fireTry! It is separated here for performance. 42 | 43 | isSafeToRemoveObserver = false 44 | 45 | externalObservers.foreach { observer => 46 | observer.onError(nextError) 47 | } 48 | 49 | internalObservers.foreach { observer => 50 | InternalObserver.onError(observer, nextError, transaction) 51 | } 52 | 53 | isSafeToRemoveObserver = true 54 | 55 | maybePendingObserverRemovals.foreach { pendingObserverRemovals => 56 | pendingObserverRemovals.forEach(remove => remove()) 57 | pendingObserverRemovals.length = 0 58 | } 59 | } 60 | 61 | final override protected[this] def fireTry(nextValue: Try[A], transaction: Transaction): Unit = { 62 | nextValue.fold( 63 | fireError(_, transaction), 64 | fireValue(_, transaction) 65 | ) 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/debug/DebuggableSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.debug 2 | 3 | import com.raquo.airstream.core.Signal 4 | import com.raquo.airstream.util.always 5 | 6 | import scala.scalajs.js 7 | import scala.util.{Failure, Success, Try} 8 | 9 | /** This implicit class provides Signal-specific debug* methods, e.g.: 10 | * 11 | * {{{ 12 | * signal.debugLogInitialEval().debugLog() 13 | * }}} 14 | * 15 | * See [[DebuggableObservable]] and the docs for details. 16 | * 17 | * The implicit conversion to this class is defined in the [[Signal]] companion object. 18 | * 19 | * This is not a value class because it needs to extend [[DebuggableObservable]]. 20 | * The performance penalty of one extra instantiation per debugged stream should 21 | * not be noticeable. 22 | */ 23 | class DebuggableSignal[+A](override val observable: Signal[A]) extends DebuggableObservable[Signal, A](observable) { 24 | 25 | /** Execute fn when signal is evaluating its `currentValueFromParent`. 26 | * This is typically triggered when evaluating signal's initial value onStart, 27 | * as well as on subsequent re-starts when the signal is syncing its value 28 | * to the parent's new current value. */ 29 | def debugSpyEvalFromParent(fn: Try[A] => Unit): Signal[A] = { 30 | val debugger = Debugger(onEvalFromParent = fn) 31 | observable.debugWith(debugger) 32 | } 33 | 34 | /** Log when signal is evaluating its initial value (if `when` passes at that time) */ 35 | def debugLogEvalFromParent( 36 | when: Try[A] => Boolean = always, 37 | useJsLogger: Boolean = false 38 | ): Signal[A] = { 39 | debugSpyEvalFromParent { value => 40 | if (when(value)) { 41 | value match { 42 | case Success(ev) => log("eval-from-parent", Some(ev), useJsLogger) 43 | case Failure(err) => log("eval-from-parent[error]", Some(err), useJsLogger) 44 | } 45 | } 46 | } 47 | } 48 | 49 | /** Trigger JS debugger when signal is evaluating its initial value (if `when` passes at that time) */ 50 | def debugBreakEvalFromParent(when: Try[A] => Boolean = always): Signal[A] = { 51 | debugSpyEvalFromParent { value => 52 | if (when(value)) { 53 | js.special.debugger() 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/ownership/Owner.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.ownership 2 | 3 | import com.raquo.ew.JsArray 4 | 5 | import scala.annotation.unused 6 | 7 | /** Owner decides when to kill its subscriptions. 8 | * - Ownership is defined at creation of the [[Subscription]] 9 | * - Ownership is non-transferable 10 | * - There is no way to unkill a Subscription 11 | * - In other words: Owner can only own a Subscription once, 12 | * and a Subscription can only ever be owned by its initial owner 13 | * - Owner can still be used after calling killPossessions, but the canonical 14 | * use case is for the Owner to kill its possessions when the owner itself 15 | * is discarded (e.g. a UI component is unmounted). 16 | * 17 | * If you need something more flexible, use [[DynamicOwner]], 18 | * or build your own custom logic on top of this in a similar manner. 19 | */ 20 | trait Owner { 21 | 22 | /** Note: This is enforced to be a sorted set outside the type system. #performance */ 23 | protected[this] val subscriptions: JsArray[Subscription] = JsArray() 24 | 25 | protected[this] def killSubscriptions(): Unit = { 26 | subscriptions.forEach(_.onKilledByOwner()) 27 | subscriptions.length = 0 28 | } 29 | 30 | // @TODO[API] This method only exists because I can't figure out how to better deal with permissions. 31 | @inline private[ownership] def _killSubscriptions(): Unit = killSubscriptions() 32 | 33 | /** This method will be called when this [[Owner]] has just started owning this resource. 34 | * You can override it to add custom behaviour. 35 | * Note: You can rely on this base method being empty. 36 | */ 37 | protected[this] def onOwned(@unused subscription: Subscription): Unit = () 38 | 39 | private[ownership] def onKilledExternally(subscription: Subscription): Unit = { 40 | val index = subscriptions.indexOf(subscription) 41 | if (index != -1) { 42 | subscriptions.splice(index, deleteCount = 1) 43 | } else { 44 | throw new Exception("Can not remove Subscription from Owner: subscription not found.") 45 | } 46 | } 47 | 48 | private[ownership] def own(subscription: Subscription): Unit = { 49 | subscriptions.push(subscription) 50 | onOwned(subscription) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/TryStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{EventStream, Signal} 4 | 5 | import scala.util.{Failure, Success, Try} 6 | 7 | class TryStream[A](val stream: EventStream[Try[A]]) extends AnyVal { 8 | 9 | /** Emit `x` if parent stream emits `Right(x)`, do nothing otherwise */ 10 | def collectSuccess: EventStream[A] = stream.collect { case Success(ev) => ev } 11 | 12 | /** Emit `pf(x)` if parent stream emits `Success(x)` and `pf` is defined for `x`, do nothing otherwise */ 13 | def collectSuccess[C](pf: PartialFunction[A, C]): EventStream[C] = { 14 | stream.collectOpt(_.toOption.collect(pf)) 15 | } 16 | 17 | /** Emit `x` if parent stream emits `Left(x)`, do nothing otherwise */ 18 | def collectFailure: EventStream[Throwable] = stream.collect { case Failure(ev) => ev } 19 | 20 | /** Emit `pf(x)` if parent stream emits `Failure(x)` and `pf` is defined for `x`, do nothing otherwise */ 21 | def collectFailure[C](pf: PartialFunction[Throwable, C]): EventStream[C] = { 22 | stream.collectOpt(_.toEither.left.toOption.collect(pf)) 23 | } 24 | 25 | /** This `.split`-s a stream of Try-s by their `isSuccess` property. 26 | * If you want a different key, use the .splitOne operator directly. 27 | * 28 | * @param success (initialSuccess, signalOfSuccessValues) => output 29 | * `success` is called whenever `stream` switches from `Failure()` to `Success()`. 30 | * `signalOfSuccessValues` starts with `initialSuccess` value, and updates when 31 | * the parent stream updates from `Success(a)` to `Success(b)`. 32 | * @param failure (initialFailure, signalOfFailureValues) => output 33 | * `failure` is called whenever `stream` switches from `Success()` to `Failure()`. 34 | * `signalOfFailureValues` starts with `initialFailure` value, and updates when 35 | * the parent stream updates from `Failure(a)` to `Failure(b)`. 36 | */ 37 | def splitTry[B]( 38 | success: (A, Signal[A]) => B, 39 | failure: (Throwable, Signal[Throwable]) => B 40 | ): EventStream[B] = { 41 | new EitherStream(stream.mapToEither).splitEither(failure, success) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/StreamFromSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.{InternalTryObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{Protected, Signal, Transaction} 5 | 6 | import scala.util.Try 7 | 8 | class StreamFromSignal[A]( 9 | override protected[this] val parent: Signal[A], 10 | changesOnly: Boolean 11 | ) extends SingleParentStream[A, A] with InternalTryObserver[A] { 12 | 13 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 14 | 15 | private[this] var lastSeenParentUpdateId: Int = 0 16 | 17 | private[this] var isFirstPull: Boolean = true 18 | 19 | override protected[this] def onStart(): Unit = { 20 | val newParentLastUpdateId = Protected.lastUpdateId(parent) 21 | if (isFirstPull && changesOnly) { 22 | lastSeenParentUpdateId = newParentLastUpdateId 23 | } else { 24 | if (newParentLastUpdateId != lastSeenParentUpdateId) { 25 | // #TODO[Integrity] In this branch, should lastSeenParentUpdateId be updated immediately, 26 | // or once the transaction executes? If the latter – suppose the transaction doesn't execute, 27 | // because this stream was stopped for whatever weird reason (is that even possible?). 28 | // This would mean that on next onStart, this stream would treat the same 29 | // `newParentLastUpdateId` value as unseen, and try to pull it again. Seems reasonable? 30 | Transaction.onStart.add { trx => 31 | if (isStarted) { 32 | // #TODO[Integrity] Do we need to check `newParentLastUpdateId != lastSeenParentUpdateId` again here? 33 | // We fetch new parent value and new corresponding lastUpdateId, just in case, 34 | // to make sure that we're getting the latest value. 35 | fireTry(parent.tryNow(), trx) 36 | lastSeenParentUpdateId = Protected.lastUpdateId(parent) 37 | } 38 | } 39 | } 40 | } 41 | isFirstPull = false 42 | 43 | super.onStart() 44 | } 45 | 46 | override protected def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 47 | fireTry(nextValue, transaction) 48 | lastSeenParentUpdateId = Protected.lastUpdateId(parent) 49 | isFirstPull = false 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/status/StatusSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.status 2 | 3 | import com.raquo.airstream.AsyncUnitSpec 4 | import com.raquo.airstream.core.Observer 5 | import com.raquo.airstream.eventbus.EventBus 6 | import com.raquo.airstream.fixtures.{Effect, TestableOwner} 7 | import org.scalatest.BeforeAndAfter 8 | 9 | import scala.collection.mutable 10 | 11 | class StatusSpec extends AsyncUnitSpec with BeforeAndAfter { 12 | 13 | implicit val owner: TestableOwner = new TestableOwner 14 | 15 | val effects = mutable.Buffer[Effect[Status[Int, String]]]() 16 | 17 | val obs1 = Observer[Status[Int, String]](effects += Effect("obs1", _)) 18 | 19 | before { 20 | owner.killSubscriptions() 21 | effects.clear() 22 | } 23 | 24 | // #TODO[Test] Test with something like debounce to ensure that we are NOT 25 | // creating a new output stream for every input stream, i.e. that we don't 26 | // use flatMapSwitch inside the implementation. 27 | 28 | it("delayWithStatus, mapOutput") { 29 | val bus = new EventBus[Int] 30 | 31 | val stream = bus.events.delayWithStatus(30).mapOutput(_.toString) 32 | 33 | val sub = stream.addObserver(obs1) 34 | 35 | assert(effects.isEmpty) 36 | 37 | // -- 38 | 39 | bus.emit(1) 40 | 41 | assertEquals( 42 | effects.toList, 43 | List( 44 | Effect("obs1", Pending(1)) 45 | ) 46 | ) 47 | effects.clear() 48 | 49 | for { 50 | _ <- delay { 51 | assert(effects.isEmpty) 52 | } 53 | 54 | _ <- delay(30) { 55 | assertEquals( 56 | effects.toList, 57 | List( 58 | Effect("obs1", Resolved(1, "1", 1)) 59 | ) 60 | ) 61 | effects.clear() 62 | 63 | // -- 64 | 65 | bus.emit(2) 66 | 67 | assertEquals( 68 | effects.toList, 69 | List( 70 | Effect("obs1", Pending(2)) 71 | ) 72 | ) 73 | effects.clear() 74 | } 75 | 76 | _ <- delay(30) { 77 | assertEquals( 78 | effects.toList, 79 | List( 80 | Effect("obs1", Resolved(2, "2", 1)) 81 | ) 82 | ) 83 | effects.clear() 84 | } 85 | } yield { 86 | assert(true) 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/eventbus/EventBus.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.eventbus 2 | 3 | import com.raquo.airstream.core.{EventStream, Named, Observer, Sink} 4 | import com.raquo.airstream.core.Source.EventSource 5 | 6 | import scala.util.Try 7 | 8 | /** EventBus combines a WriteBus and a stream of its events. 9 | * 10 | * `writer` and `events` are made separate to allow you to manage permissions. 11 | * For example, you can pass only the `writer` instance to a function that 12 | * should only have access to writing events, not reading all events from the bus. 13 | */ 14 | class EventBus[A] extends EventSource[A] with Sink[A] with Named { 15 | 16 | val writer: WriteBus[A] = new WriteBus[A](parentDisplayName = displayName) 17 | 18 | val events: EventStream[A] = writer.stream 19 | 20 | /** Alias to [[events]] */ 21 | val stream: EventStream[A] = events 22 | 23 | def emit(event: A): Unit = writer.onNext(event) 24 | 25 | def emitTry(event: Try[A]): Unit = writer.onTry(event) 26 | 27 | override def toObservable: EventStream[A] = events 28 | 29 | override def toObserver: Observer[A] = writer 30 | } 31 | 32 | object EventBus { 33 | 34 | implicit class EventBusTuple[A](val tuple: (EventBus[A], A)) extends AnyVal 35 | 36 | implicit class EventBusTryTuple[A](val tuple: (EventBus[A], Try[A])) extends AnyVal 37 | 38 | def apply[A](): EventBus[A] = new EventBus[A] 39 | 40 | /** Emit events into several EventBus-es at once (in the same transaction) 41 | * Example usage: emitTry(eventBus1 -> value1, eventBus2 -> value2) 42 | */ 43 | def emit( 44 | values: EventBusTuple[_]* 45 | ): Unit = { 46 | WriteBus.emit(values.map(toWriterTuple(_)): _*) 47 | } 48 | 49 | /** Emit events into several WriteBus-es at once (in the same transaction) 50 | * Example usage: emitTry(eventBus1 -> Success(value1), eventBus2 -> Failure(error2)) 51 | */ 52 | def emitTry( 53 | values: EventBusTryTuple[_]* 54 | ): Unit = { 55 | WriteBus.emitTry(values.map(toWriterTryTuple(_)): _*) 56 | } 57 | 58 | @inline private def toWriterTuple[A](t: EventBusTuple[A]): WriteBus.BusTuple[A] = 59 | new WriteBus.BusTuple((t.tuple._1.writer, t.tuple._2)) 60 | 61 | @inline private def toWriterTryTuple[A](t: EventBusTryTuple[A]): WriteBus.BusTryTuple[A] = 62 | new WriteBus.BusTryTuple((t.tuple._1.writer, t.tuple._2)) 63 | } 64 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/split/DuplicateKeysConfig.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | /** .split() operator does not tolerate duplicate keys, 4 | * i.e. the Seq you provide it must not contain records 5 | * that have the same `key(record)`. 6 | * 7 | * However, checking for duplicate keys can get expensive when 8 | * splitting very large lists, so this setting can be used to 9 | * disable the checks. 10 | * 11 | * When warnings are enabled, YOUR CODE WILL STILL BREAK if the 12 | * .split() operator encounters duplicate keys, but it will 13 | * first report an error as `unhandled` in Airstream, which by 14 | * default will print it as an error in the browser console, 15 | * listing the duplicate keys at fault. 16 | * 17 | * We enable this setting by default to aid in debugging. As the 18 | * end user, you might want to disable this either globally or 19 | * for specific .split() usages to improve performance on very 20 | * large lists. 21 | * 22 | * #TODO: Add more granular control later, if there is demand for that. 23 | * For example, we could instruct Airstream to skip duplicate keys, or 24 | * to raise an exception if a duplicate happens. In the latter case 25 | * perhaps we could do that by catching the right exception, without 26 | * the overhead of checking for duplicates. But not sure how bulletproof 27 | * that logic would be. 28 | */ 29 | class DuplicateKeysConfig(private var _shouldWarn: Boolean) { 30 | 31 | def shouldWarn: Boolean = _shouldWarn 32 | 33 | /** Only ever call this on `DuplicateKeysConfig.default`, if you want to change the default. */ 34 | private def setShouldWarn(newValue: Boolean): Unit = { 35 | _shouldWarn = newValue 36 | } 37 | } 38 | 39 | object DuplicateKeysConfig { 40 | 41 | /** Note: If you want to set a default, do it immediately at 42 | * application startup time to avoid the perils of global mutable vars. 43 | */ 44 | def setDefault(newDefault: DuplicateKeysConfig): Unit = { 45 | // Do not reuse the mutable reference, copy its properties 46 | default.setShouldWarn(newDefault.shouldWarn) 47 | } 48 | 49 | val default: DuplicateKeysConfig = new DuplicateKeysConfig(_shouldWarn = true) 50 | 51 | val warnings: DuplicateKeysConfig = new DuplicateKeysConfig(_shouldWarn = true) 52 | 53 | val noWarnings: DuplicateKeysConfig = new DuplicateKeysConfig(_shouldWarn = false) 54 | } 55 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/combine/SampleCombineSignalN.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.combine 2 | 3 | import com.raquo.airstream.common.{InternalParentObserver, MultiParentSignal} 4 | import com.raquo.airstream.core.{Protected, Signal} 5 | import com.raquo.ew.JsArray 6 | 7 | import scala.util.Try 8 | 9 | /** This signal emits the combined value when samplingSignal is updated. 10 | * 11 | * When the combined signal emits, it looks up the current value of sampledSignals, 12 | * but updates to those signals do not trigger updates to the combined stream. 13 | * 14 | * Works similar to Rx's "withLatestFrom", except without glitches (see a diamond case test for this in GlitchSpec). 15 | * 16 | * @param sampledSignals Never update this array - this signal owns it. 17 | * @param combinator Note: Must not throw! Must be pure. 18 | */ 19 | class SampleCombineSignalN[A, Out]( 20 | samplingSignal: Signal[A], 21 | sampledSignals: JsArray[Signal[A]], 22 | combinator: JsArray[A] => Out 23 | ) extends MultiParentSignal[A, Out] with CombineObservable[Out] { 24 | 25 | override protected val topoRank: Int = Protected.maxTopoRank(samplingSignal, sampledSignals) + 1 26 | 27 | override protected[this] def inputsReady: Boolean = true 28 | 29 | override protected[this] val parents: JsArray[Signal[A]] = { 30 | val arr = JsArray(samplingSignal) 31 | sampledSignals.forEach { sampledSignal => 32 | arr.push(sampledSignal) 33 | } 34 | arr 35 | } 36 | 37 | override protected[this] val parentObservers: JsArray[InternalParentObserver[_]] = { 38 | val arr = JsArray[InternalParentObserver[_]]( 39 | InternalParentObserver.fromTry[A](samplingSignal, (_, trx) => { 40 | onInputsReady(trx) 41 | }) 42 | ) 43 | sampledSignals.forEach { sampledSignal => 44 | arr.push( 45 | InternalParentObserver.fromTry[A](sampledSignal, (_, _) => { 46 | // Do nothing, we just want to ensure that sampledSignal is started. 47 | }) 48 | ) 49 | } 50 | arr 51 | } 52 | 53 | override protected[this] def combinedValue: Try[Out] = { 54 | val values = JsArray(samplingSignal.tryNow()) 55 | sampledSignals.forEach { sampledSignal => 56 | values.push(sampledSignal.tryNow()) 57 | } 58 | CombineObservable.jsArrayCombinator(values, combinator) 59 | } 60 | 61 | override protected def currentValueFromParent(): Try[Out] = combinedValue 62 | } 63 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/TakeStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{EventStream, Protected, Transaction} 5 | 6 | /** Event stream that mimics the parent event stream (both events and errors) for as long as `takeWhile` returns true. 7 | * As soon as `takeWhile` returns `false` for the first time, it stops emitting anything. 8 | * 9 | * @param takeWhile nextEvent => shouldDrop 10 | * Function which determines whether this stream should take the given event. 11 | * Warning: MUST NOT THROW! 12 | * 13 | * @param reset This is called when this stream is stopped if resetOnStop is true. Use it to 14 | * reset your `takeWhile` function's internal state, if needed. 15 | * Warning: MUST NOT THROW! 16 | * 17 | * @param resetOnStop If true, stopping this stream will reset the stream's memory of previously 18 | * taken events (up to you to implement the `reset` as far as your `takeWhile` 19 | * function is concerned though). 20 | */ 21 | class TakeStream[A]( 22 | override protected val parent: EventStream[A], 23 | takeWhile: A => Boolean, 24 | reset: () => Unit, 25 | resetOnStop: Boolean 26 | ) extends SingleParentStream[A, A] with InternalNextErrorObserver[A] { 27 | 28 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 29 | 30 | private var disableTaking: Boolean = false 31 | 32 | override protected def onNext(nextValue: A, transaction: Transaction): Unit = { 33 | val shouldTakeNextValue = !disableTaking && { 34 | val takeNext = takeWhile(nextValue) 35 | disableTaking = !takeNext 36 | takeNext 37 | } 38 | if (shouldTakeNextValue) { 39 | fireValue(nextValue, transaction) 40 | } else { 41 | // println(s"!!! DROPPED event `$nextParentValue` from ${this}. Total drops: $numDropped") 42 | } 43 | } 44 | 45 | override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { 46 | if (!disableTaking) { 47 | fireError(nextError, transaction) 48 | } 49 | } 50 | 51 | override protected[this] def onStop(): Unit = { 52 | if (resetOnStop) { 53 | disableTaking = false 54 | reset() 55 | } 56 | super.onStop() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/OptionVar.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.Signal 4 | import com.raquo.airstream.split.DuplicateKeysConfig 5 | import com.raquo.airstream.state.{LazyDerivedVar, LazyStrictSignal, Var} 6 | 7 | class OptionVar[A](val v: Var[Option[A]]) extends AnyVal { 8 | 9 | /** This `.split`-s a Var of an Option by the Option's `isDefined` property. 10 | * If you want a different key, use the .split operator directly. 11 | * 12 | * @param project - (initialInput, varOfInput) => output 13 | * `project` is called whenever the parent var switches from `None` to `Some()`. 14 | * `varOfInput` starts with `initialInput` value, and updates when 15 | * the parent var updates from `Some(a)` to `Some(b)`. 16 | * @param ifEmpty - returned if Option is empty. Evaluated whenever the parent var 17 | * switches from `Some(a)` to `None`, or when the parent var 18 | * starts with a `None`. `ifEmpty` is NOT re-evaluated when the 19 | * parent var emits `None` if its value is already `None`. 20 | */ 21 | def splitOption[B]( 22 | project: (A, Var[A]) => B, 23 | ifEmpty: => B 24 | ): Signal[B] = { 25 | // Note: We never have duplicate keys here, so we can use 26 | // DuplicateKeysConfig.noWarnings to improve performance 27 | v.signal 28 | .distinctByFn((prev, next) => prev.isEmpty && next.isEmpty) // Ignore consecutive `None` events 29 | .split( 30 | key = _ => (), 31 | duplicateKeys = DuplicateKeysConfig.noWarnings 32 | ) { (_, initial, signal) => 33 | val displayNameSuffix = s".splitOption(Some)" 34 | val childVar = new LazyDerivedVar[Option[A], A]( 35 | parent = v, 36 | signal = new LazyStrictSignal[A, A]( 37 | signal, identity, signal.displayName, displayNameSuffix + ".signal" 38 | ), 39 | zoomOut = (inputs, newInput) => { 40 | Some(newInput) 41 | }, 42 | displayNameSuffix = displayNameSuffix 43 | ) 44 | project(initial, childVar) 45 | } 46 | .map(_.getOrElse(ifEmpty)) 47 | } 48 | 49 | def splitOption[B]( 50 | project: (A, Var[A]) => B 51 | ): Signal[Option[B]] = { 52 | splitOption( 53 | (initial, someVar) => Some(project(initial, someVar)), 54 | ifEmpty = None 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/DropStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{EventStream, Protected, Transaction} 5 | 6 | /** Event stream that mimics the parent event stream, except that first it skips (drops) the parent's events, for as 7 | * long as `dropWhile` returns true. As soon as it returns false for the first time, it starts mirroring the parent 8 | * stream faithfully. 9 | * 10 | * Note: only events are dropped, not errors. 11 | * 12 | * @param dropWhile `nextEvent => shouldDrop` 13 | * Function which determines whether this stream should drop the given event. 14 | * Warning: MUST NOT THROW! 15 | * 16 | * @param reset This is called when this stream is stopped if resetOnStop is true. Use it to 17 | * reset your `dropWhile` function's internal state, if needed. 18 | * Warning: MUST NOT THROW! 19 | * 20 | * @param resetOnStop If true, stopping this stream will reset the stream's memory of previously 21 | * dropped events (up to you to implement the `reset` as far as your `dropWhile` 22 | * function is concerned though). 23 | */ 24 | class DropStream[A]( 25 | override protected val parent: EventStream[A], 26 | dropWhile: A => Boolean, 27 | reset: () => Unit, 28 | resetOnStop: Boolean 29 | ) extends SingleParentStream[A, A] with InternalNextErrorObserver[A] { 30 | 31 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 32 | 33 | private var disableDropping: Boolean = false 34 | 35 | override protected def onNext(nextValue: A, transaction: Transaction): Unit = { 36 | val shouldDropNextValue = !disableDropping && { 37 | val dropNext = dropWhile(nextValue) 38 | disableDropping = !dropNext 39 | dropNext 40 | } 41 | if (!shouldDropNextValue) { 42 | fireValue(nextValue, transaction) 43 | } else { 44 | // println(s"!!! DROPPED event `$nextParentValue` from ${this}. Total drops: $numDropped") 45 | } 46 | } 47 | 48 | override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { 49 | fireError(nextError, transaction) 50 | } 51 | 52 | override protected[this] def onStop(): Unit = { 53 | if (resetOnStop) { 54 | disableDropping = false 55 | reset() 56 | } 57 | super.onStop() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/LazyDerivedVar.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.{AirstreamError, Transaction} 4 | import com.raquo.airstream.core.AirstreamError.VarError 5 | 6 | import scala.util.{Failure, Success, Try} 7 | 8 | /** LazyDerivedVar has the same Var contract as DerivedVar, 9 | * but it only computes its value lazily, e.g. when you 10 | * ask for it with .now(), or when its signal has subscribers. 11 | * 12 | * Unlike the regular DerivedVar, you don't need to provide an Owner 13 | * to create LazyDerivedVar, and you're allowed to update this Var 14 | * even if its signal has no subscribers. 15 | * 16 | * @param zoomOut (currentParentValue, nextValue) => nextParentValue. 17 | */ 18 | class LazyDerivedVar[A, B]( 19 | parent: Var[A], 20 | override val signal: StrictSignal[B], 21 | zoomOut: (A, B) => A, 22 | displayNameSuffix: String 23 | ) extends Var[B] { 24 | 25 | override private[state] def underlyingVar: SourceVar[_] = parent.underlyingVar 26 | 27 | // #Note this getCurrentValue implementation is different from SourceVar 28 | // - SourceVar's getCurrentValue looks at an internal currentValue variable 29 | // - That currentValue gets updated immediately before the signal (in an already existing transaction) 30 | // - I hope this doesn't introduce weird transaction related timing glitches 31 | // - But even if it does, I think keeping derived var's current value consistent with its signal value 32 | // is more important, otherwise it would be madness if the derived var was accessed after its owner 33 | // was killed 34 | override private[state] def getCurrentValue: Try[B] = signal.tryNow() 35 | 36 | override private[state] def setCurrentValue(value: Try[B], transaction: Transaction): Unit = { 37 | parent.signal.tryNow() match { 38 | case Success(parentValue) => 39 | // This can update the parent without causing an infinite loop because 40 | // the parent updates this derived var's signal, it does not call 41 | // setCurrentValue on this var directly. 42 | val nextValue = value.map(zoomOut(parentValue, _)) 43 | parent.setCurrentValue(nextValue, transaction) 44 | 45 | case Failure(err) => 46 | AirstreamError.sendUnhandledError(VarError(s"Unable to zoom out of lazy derived var when the parent var is failed.", cause = Some(err))) 47 | } 48 | } 49 | 50 | override protected def defaultDisplayName: String = parent.displayName + displayNameSuffix 51 | } 52 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/state/LazyDerivedVar2.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.state 2 | 3 | import com.raquo.airstream.core.Transaction 4 | 5 | import scala.util.{Failure, Success, Try} 6 | 7 | /** #nc: this is replacement for LazyDerivedVar class, added for bin compat in 17.2.0 release. 8 | * - In 18.0, this will become the one and only LazyDerivedVar 9 | * 10 | * [[LazyDerivedVar2]] has the same Var contract as DerivedVar, 11 | * but it only computes its value lazily, e.g. when you 12 | * ask for it with .now(), or when its signal has subscribers. 13 | * 14 | * Unlike the regular DerivedVar, you don't need to provide an Owner 15 | * to create LazyDerivedVar, and you're allowed to update this Var 16 | * even if its signal has no subscribers. 17 | * 18 | * @param updateParent (currentParentValue, nextValue) => nextParentValue. 19 | */ 20 | class LazyDerivedVar2[A, B]( 21 | parent: Var[A], 22 | override val signal: StrictSignal[B], 23 | updateParent: (Try[A], Try[B]) => Option[Try[A]], 24 | displayNameSuffix: String 25 | ) extends Var[B] { 26 | 27 | override private[state] def underlyingVar: SourceVar[_] = parent.underlyingVar 28 | 29 | // #Note this getCurrentValue implementation is different from SourceVar 30 | // - SourceVar's getCurrentValue looks at an internal currentValue variable 31 | // - That currentValue gets updated immediately before the signal (in an already existing transaction) 32 | // - I hope this doesn't introduce weird transaction related timing glitches 33 | // - But even if it does, I think keeping derived var's current value consistent with its signal value 34 | // is more important, otherwise it would be madness if the derived var was accessed after its owner 35 | // was killed 36 | override private[state] def getCurrentValue: Try[B] = signal.tryNow() 37 | 38 | override private[state] def setCurrentValue(value: Try[B], transaction: Transaction): Unit = { 39 | val maybeNextValue = Try(updateParent(parent.tryNow(), value)) match { 40 | case Success(nextValue) => nextValue 41 | case Failure(err) => Some(Failure(err)) 42 | } 43 | maybeNextValue.foreach { nextValue => 44 | // This can update the parent without causing an infinite loop because 45 | // the parent updates this derived var's signal, it does not call 46 | // setCurrentValue on this var directly. 47 | parent.setCurrentValue(nextValue, transaction) 48 | } 49 | } 50 | 51 | override protected def defaultDisplayName: String = parent.displayName + displayNameSuffix 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/status/Status.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.status 2 | 3 | /** Represents a combination of input event with the status 4 | * (availability) of output event(s) derived from it. 5 | * The output events typically come from a stream that is 6 | * derived from the input stream, but emitting asynchronously, 7 | * for example `inputStream.delay(1000)`. 8 | */ 9 | sealed trait Status[+In, +Out] { 10 | 11 | def isResolved: Boolean 12 | 13 | @inline def isPending: Boolean = !isResolved 14 | 15 | def mapInput[In2](project: In => In2): Status[In2, Out] 16 | 17 | def mapOutput[Out2](project: Out => Out2): Status[In, Out2] 18 | 19 | def fold[A](resolved: Resolved[In, Out] => A, pending: Pending[In] => A): A 20 | 21 | def toResolvedOption: Option[Resolved[In, Out]] 22 | 23 | def toPendingOption: Option[Pending[In]] 24 | 25 | def toResolvedInputOption: Option[In] = toResolvedOption.map(_.input) 26 | 27 | def toResolvedOutputOption: Option[Out] = toResolvedOption.map(_.output) 28 | 29 | def toPendingInputOption: Option[In] = toPendingOption.map(_.input) 30 | } 31 | 32 | /** Waiting for output for the latest input event. */ 33 | case class Pending[+In](input: In) extends Status[In, Nothing] { 34 | 35 | override def isResolved: Boolean = false 36 | 37 | override def mapInput[In2](project: In => In2): Status[In2, Nothing] = copy(input = project(input)) 38 | 39 | override def mapOutput[Out2](project: Nothing => Out2): Pending[In] = this 40 | 41 | override def fold[A]( 42 | resolved: Resolved[In, Nothing] => A, 43 | pending: Pending[In] => A 44 | ): A = { 45 | pending(this) 46 | } 47 | 48 | override def toResolvedOption: Option[Resolved[In, Nothing]] = None 49 | 50 | override def toPendingOption: Option[Pending[In]] = Some(this) 51 | } 52 | 53 | /** Output event received for this input, for the `ix`-th time (ix starts at 1). */ 54 | case class Resolved[+In, +Out](input: In, output: Out, ix: Int) extends Status[In, Out] { 55 | 56 | override def isResolved: Boolean = true 57 | 58 | override def mapInput[In2](project: In => In2): Status[In2, Out] = copy(input = project(input)) 59 | 60 | override def mapOutput[Out2](project: Out => Out2): Status[In, Out2] = copy(output = project(output)) 61 | 62 | override def fold[A]( 63 | resolved: Resolved[In, Out] => A, 64 | pending: Pending[In] => A 65 | ): A = { 66 | resolved(this) 67 | } 68 | 69 | override def toResolvedOption: Option[Resolved[In, Out]] = Some(this) 70 | 71 | override def toPendingOption: Option[Pending[In]] = None 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/extensions/EitherStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.core.{EventStream, Signal} 4 | import com.raquo.airstream.split.SplittableOneStream 5 | 6 | /** See also: [[EitherObservable]] */ 7 | class EitherStream[A, B](val stream: EventStream[Either[A, B]]) extends AnyVal { 8 | 9 | /** Emit `x` if parent stream emits `Left(x)`, do nothing otherwise */ 10 | def collectLeft: EventStream[A] = stream.collect { case Left(ev) => ev } 11 | 12 | /** Emit `pf(x)` if parent stream emits `Left(x)` and `pf` is defined for `x`, do nothing otherwise */ 13 | def collectLeft[C](pf: PartialFunction[A, C]): EventStream[C] = { 14 | stream.collectOpt(_.left.toOption.collect(pf)) 15 | } 16 | 17 | /** Emit `x` if parent stream emits `Right(x)`, do nothing otherwise */ 18 | def collectRight: EventStream[B] = stream.collect { case Right(ev) => ev } 19 | 20 | /** Emit `pf(x)` if parent stream emits `Right(x)` and `pf` is defined for `x`, do nothing otherwise */ 21 | def collectRight[C](pf: PartialFunction[B, C]): EventStream[C] = { 22 | stream.collectOpt(_.toOption.collect(pf)) 23 | } 24 | 25 | /** This `.split`-s a stream of Either-s by their `isRight` property. 26 | * If you want a different key, use the .splitOne operator directly. 27 | * 28 | * @param left (initialLeft, signalOfLeftValues) => output 29 | * `left` is called whenever `stream` switches from `Right()` to `Left()`. 30 | * `signalOfLeftValues` starts with `initialLeft` value, and updates when 31 | * the parent stream updates from `Left(a)` to Left(b)`. 32 | * @param right (initialRight, signalOfRightValues) => output 33 | * `right` is called whenever `stream` switches from `Left()` to `Right()`. 34 | * `signalOfRightValues` starts with `initialRight` value, and updates when 35 | * the parent stream updates from `Right(a)` to `Right(b)`. 36 | */ 37 | def splitEither[C]( 38 | left: (A, Signal[A]) => C, 39 | right: (B, Signal[B]) => C 40 | ): EventStream[C] = { 41 | new SplittableOneStream(stream).splitOne(key = _.isRight) { 42 | (_, initial, signal) => 43 | initial match { 44 | case Right(v) => 45 | right(v, signal.map(e => e.getOrElse(throw new Exception(s"splitEither: `${stream}` bad right value: $e")))) 46 | case Left(v) => 47 | left(v, signal.map(e => e.left.getOrElse(throw new Exception(s"splitEither: `${stream}` bad left value: $e")))) 48 | } 49 | } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/misc/MapStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.misc 2 | 3 | import com.raquo.airstream.common.{InternalNextErrorObserver, SingleParentStream} 4 | import com.raquo.airstream.core.{Observable, Protected, Transaction} 5 | import com.raquo.airstream.core.AirstreamError.ErrorHandlingError 6 | 7 | import scala.util.Try 8 | 9 | /** This stream applies a `project` function to events fired by its parent and fires the resulting value 10 | * 11 | * This stream emits an error if the parent observable emits an error or if `project` throws 12 | * 13 | * If `recover` is defined and needs to be called, it can do the following: 14 | * - Return Some(value) to make this stream emit value 15 | * - Return None to make this stream ignore (swallow) this error 16 | * - Not handle the error (meaning .isDefinedAt(error) must be false) to emit the original error 17 | * 18 | * If `recover` throws an exception, it will be wrapped in `ErrorHandlingError` and propagated. 19 | * 20 | * @param project Note: guarded against exceptions 21 | * @param recover Note: guarded against exceptions 22 | */ 23 | class MapStream[I, O]( 24 | override protected[this] val parent: Observable[I], 25 | project: I => O, 26 | recover: Option[PartialFunction[Throwable, Option[O]]] 27 | ) extends SingleParentStream[I, O] with InternalNextErrorObserver[I] { 28 | 29 | override protected val topoRank: Int = Protected.topoRank(parent) + 1 30 | 31 | override protected def onNext(nextParentValue: I, transaction: Transaction): Unit = { 32 | Try(project(nextParentValue)).fold( 33 | onError(_, transaction), 34 | fireValue(_, transaction) 35 | ) 36 | } 37 | 38 | override protected def onError(nextError: Throwable, transaction: Transaction): Unit = { 39 | recover.fold( 40 | // if no `recover` specified, fire original error 41 | ifEmpty = fireError(nextError, transaction) 42 | ) { pf => 43 | Try(pf.applyOrElse(nextError, (_: Throwable) => null)).fold( 44 | tryError => { 45 | // if recover throws error, fire a wrapped error 46 | fireError(ErrorHandlingError(error = tryError, cause = nextError), transaction) 47 | }, 48 | nextValue => { 49 | if (nextValue == null) { 50 | // If recover was not applicable, fire original error 51 | fireError(nextError, transaction) 52 | } else { 53 | // If recover was applicable and resulted in a new value, fire that value 54 | nextValue.foreach(fireValue(_, transaction)) 55 | } 56 | } 57 | ) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/scala-3/com/raquo/airstream/split/MacrosUtilities.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.split 2 | 3 | import scala.quoted.{Expr, Quotes, Type} 4 | import scala.annotation.tailrec 5 | import scala.annotation.compileTimeOnly 6 | 7 | private[split] object MacrosUtilities { 8 | 9 | type CaseAny = Any 10 | type HandlerAny[+O] = Any 11 | 12 | final case class MatchTypeHandler[T] private (private val underlying: Unit) extends AnyVal 13 | 14 | object MatchTypeHandler { 15 | @compileTimeOnly("MatchTypeHandler[T] cannot be used at runtime") 16 | def instance[T]: MatchTypeHandler[T] = throw new UnsupportedOperationException("MatchTypeHandler[T] cannot be used at runtime") 17 | } 18 | 19 | final case class MatchValueHandler[V] private (private val underlying: Unit) extends AnyVal 20 | 21 | object MatchValueHandler { 22 | @compileTimeOnly("MatchValueHandler[V] cannot be used at runtime") 23 | def instance[V](v: V): MatchValueHandler[V] = throw new UnsupportedOperationException("MatchValueHandler[V] cannot be used at runtime") 24 | } 25 | 26 | def innerObservableImpl[I: Type]( 27 | iExpr: Expr[I], 28 | caseExprSeq: Seq[Expr[CaseAny]] 29 | )( 30 | using quotes: Quotes 31 | ) = { 32 | import quotes.reflect.* 33 | 34 | @tailrec 35 | def getCaseDef( 36 | idx: Int, 37 | term: Term 38 | ): List[CaseDef] = { 39 | term match { 40 | case Inlined(_, _, inlinedTerm) => getCaseDef(idx, inlinedTerm) 41 | case Lambda(_, Match(_, caseDefList)) => { 42 | caseDefList.map { caseDef => 43 | val idxExpr = Expr.apply(idx) 44 | val newRhsExpr = '{ 45 | val res = ${ caseDef.rhs.asExprOf[Any] }; ($idxExpr, res) 46 | } 47 | CaseDef.copy(caseDef)( 48 | caseDef.pattern, 49 | caseDef.guard, 50 | newRhsExpr.asTerm 51 | ) 52 | } 53 | } 54 | case _ => 55 | report.errorAndAbort( 56 | "Macro expansion failed, please use `handleCase` with annonymous partial function" 57 | ) 58 | } 59 | } 60 | 61 | val allCaseDefLists = caseExprSeq.view 62 | .zipWithIndex 63 | .flatMap { case (caseExpr, idx) => 64 | getCaseDef(idx, caseExpr.asTerm) 65 | } 66 | .map(_.changeOwner(Symbol.spliceOwner)) 67 | .toList 68 | 69 | Match(iExpr.asTerm, allCaseDefLists).asExprOf[(Int, Any)] 70 | } 71 | 72 | object ShowType { 73 | def nameOfExpr[CC[_]](using Type[CC], Quotes): Expr[String] = Expr(Type.show[CC]) 74 | inline def nameOf[CC[_]] = ${ nameOfExpr[CC] } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/timing/PeriodicStream.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.timing 2 | 3 | import com.raquo.airstream.core.{Transaction, WritableStream} 4 | 5 | import scala.scalajs.js 6 | import scala.util.{Failure, Success, Try} 7 | 8 | // #TODO[API] Since this has an initial value, should this be a signal perhaps? 9 | 10 | /** @param next `(currentState => (nextState, nextIntervalMs)` 11 | * Note: guarded against exceptions. 12 | * If `next` throws, stream will emit that error 13 | */ 14 | class PeriodicStream[A]( 15 | initial: A, 16 | next: A => Option[(A, Int)], 17 | resetOnStop: Boolean 18 | ) extends WritableStream[A] { 19 | 20 | override protected val topoRank: Int = 1 21 | 22 | private var currentValue: A = initial 23 | 24 | private var maybeTimeoutHandle: js.UndefOr[js.timers.SetTimeoutHandle] = js.undefined 25 | 26 | // @TODO[API] Not a fan of exposing the ability to write to a stream on the stream itself, 27 | // we separate this out on EventBus and Var 28 | def resetTo(value: A): Unit = { 29 | resetTo(value, tickNext = true) 30 | } 31 | 32 | private def resetTo(value: A, tickNext: Boolean): Unit = { 33 | clearTimeout() 34 | currentValue = value 35 | if (tickNext && isStarted) { 36 | tick() 37 | } 38 | } 39 | 40 | private def clearTimeout(): Unit = { 41 | maybeTimeoutHandle.foreach(js.timers.clearTimeout) 42 | maybeTimeoutHandle = js.undefined 43 | } 44 | 45 | private def tick(): Unit = { 46 | Transaction { trx => // #Note[onStart,trx,async] 47 | if (isStarted) { 48 | // This cycle should also be broken by clearTimeout() in onStop, 49 | // but just in case of some weird timing I put isStarted check here. 50 | fireValue(currentValue, trx) 51 | setNext() 52 | } 53 | } 54 | } 55 | 56 | private def setNext(): Unit = { 57 | Try(next(currentValue)) match { 58 | case Success(Some((nextValue, nextIntervalMs))) => 59 | currentValue = nextValue 60 | maybeTimeoutHandle = js.timers.setTimeout(nextIntervalMs.toDouble) { 61 | tick() 62 | } 63 | case Success(None) => 64 | resetTo(initial, tickNext = false) 65 | case Failure(err) => 66 | Transaction(fireError(err, _)) // #Note[onStart,trx,async] 67 | } 68 | } 69 | 70 | override protected def onWillStart(): Unit = () // noop 71 | 72 | override protected[this] def onStart(): Unit = { 73 | super.onStart() 74 | tick() 75 | } 76 | 77 | override protected[this] def onStop(): Unit = { 78 | super.onStop() 79 | clearTimeout() 80 | if (resetOnStop) { 81 | resetTo(initial) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /project/GenerateStaticSignalCombineOps.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | import java.io.File 4 | 5 | case class GenerateStaticSignalCombineOps( 6 | sourceDir: File, 7 | from: Int, 8 | to: Int 9 | ) extends SourceGenerator( 10 | sourceDir / "scala" / "com" / "raquo" / "airstream" / "combine" / "generated" / s"StaticSignalCombineOps.scala" 11 | ) { 12 | 13 | override def apply(): Unit = { 14 | line("package com.raquo.airstream.combine.generated") 15 | line() 16 | line("import com.raquo.airstream.combine.CombineSignalN") 17 | line("import com.raquo.airstream.core.Signal") 18 | line("import com.raquo.airstream.core.Source.SignalSource") 19 | line("import com.raquo.ew.JsArray") 20 | line() 21 | line("// #Warning do not edit this file directly, it is generated by GenerateStaticSignalCombineOps.scala") 22 | line() 23 | line("// These combine and combineWith methods are available on the Signal companion object") 24 | line("// For instance methods of the same name, see CombinableSignal.scala") 25 | line() 26 | enter(s"object StaticSignalCombineOps {", "}") { 27 | line() 28 | for (n <- from to to) { 29 | enter(s"def combine[${tupleType(n)}](") { 30 | line((1 to n).map(i => s"s${i}: SignalSource[T${i}]").mkString(", ")) 31 | } 32 | enter(s"): Signal[(${tupleType(n)})] = {", "}") { 33 | line(s"combineWithFn(${tupleType(n, "s")})(Tuple${n}.apply[${tupleType(n)}])") 34 | } 35 | line() 36 | line("/** @param combinator Must not throw! */") 37 | enter(s"def combineWithFn[${tupleType(n)}, Out](") { 38 | line((1 to n).map(i => s"s${i}: SignalSource[T${i}]").mkString(", ")) 39 | } 40 | enter(")(") { 41 | line(s"combinator: (${tupleType(n)}) => Out") 42 | } 43 | enter(s"): Signal[Out] = {", "}") { 44 | // line(s"val parents = JsArray(${tupleType(n, "s", ".toObservable")})") 45 | // line(s"val arrCombinator = (arr: JsArray[Any]) => combinator(${(0 until n).map(i => s"arr(${i}).asInstanceOf[T${i + 1}]").mkString(", ")})") 46 | // enter("new CombineSignalN(", ")") { 47 | // line("parents, arrCombinator") 48 | // } 49 | enter("new CombineSignalN[Any, Out](", ")") { 50 | line(s"parents = JsArray(${tupleType(n, "s", ".toObservable")}),") 51 | enter(s"combinator = arr => combinator(", ")") { 52 | for (i <- 0 until n) { 53 | line(s"arr(${i}).asInstanceOf[T${i+1}],") 54 | } 55 | } 56 | } 57 | } 58 | line() 59 | line("// --") 60 | line() 61 | } 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /project/GenerateStaticStreamCombineOps.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | import java.io.File 4 | 5 | case class GenerateStaticStreamCombineOps( 6 | sourceDir: File, 7 | from: Int, 8 | to: Int 9 | ) extends SourceGenerator( 10 | sourceDir / "scala" / "com" / "raquo" / "airstream" / "combine" / "generated" / s"StaticStreamCombineOps.scala" 11 | ) { 12 | 13 | override def apply(): Unit = { 14 | line("package com.raquo.airstream.combine.generated") 15 | line() 16 | line("import com.raquo.airstream.combine.CombineStreamN") 17 | line("import com.raquo.airstream.core.EventStream") 18 | line("import com.raquo.airstream.core.Source.EventSource") 19 | line("import com.raquo.ew.JsArray") 20 | line() 21 | line("// #Warning do not edit this file directly, it is generated by GenerateStaticStreamCombineOps.scala") 22 | line() 23 | line("// These combine and combineWith methods are available on the EventStream companion object") 24 | line("// For instance methods of the same name, see CombinableStream.scala") 25 | line() 26 | enter(s"object StaticStreamCombineOps {", "}") { 27 | line() 28 | for (n <- from to to) { 29 | enter(s"def combine[${tupleType(n)}](") { 30 | line((1 to n).map(i => s"s${i}: EventSource[T${i}]").mkString(", ")) 31 | } 32 | enter(s"): EventStream[(${tupleType(n)})] = {", "}") { 33 | line(s"combineWithFn(${tupleType(n, "s")})(Tuple${n}.apply[${tupleType(n)}])") 34 | } 35 | line() 36 | line("/** @param combinator Must not throw! */") 37 | enter(s"def combineWithFn[${tupleType(n)}, Out](") { 38 | line((1 to n).map(i => s"s${i}: EventSource[T${i}]").mkString(", ")) 39 | } 40 | enter(")(") { 41 | line(s"combinator: (${tupleType(n)}) => Out") 42 | } 43 | enter(s"): EventStream[Out] = {", "}") { 44 | // line(s"val arrCombinator = (arr: JsArray[Any]) => combinator(${(0 until n).map(i => s"arr(${i}).asInstanceOf[T${i + 1}]").mkString(", ")})") 45 | // line(s"val streams = JsArray[EventStream[Any]](${tupleType(n, "s", ".toObservable")})") 46 | // enter("new CombineStreamN(", ")") { 47 | // line("streams, arrCombinator") 48 | // } 49 | enter("new CombineStreamN[Any, Out](", ")") { 50 | line(s"parentStreams = JsArray(${tupleType(n, "s", ".toObservable")}),") 51 | enter(s"combinator = arr => combinator(", ")") { 52 | for (i <- 0 until n) { 53 | line(s"arr(${i}).asInstanceOf[T${i + 1}],") 54 | } 55 | } 56 | } 57 | } 58 | line() 59 | line("// --") 60 | line() 61 | } 62 | } 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/flatten/EventStreamFlattenFutureSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.flatten 2 | 3 | import com.raquo.airstream.AsyncUnitSpec 4 | import com.raquo.airstream.core.{EventStream, Observer} 5 | import com.raquo.airstream.eventbus.EventBus 6 | import com.raquo.airstream.fixtures.{Effect, TestableOwner} 7 | import org.scalatest.Assertion 8 | 9 | import scala.collection.mutable 10 | import scala.concurrent.{Future, Promise} 11 | 12 | class EventStreamFlattenFutureSpec extends AsyncUnitSpec { 13 | 14 | it("EventStream.flatMap(EventStream.fromFuture)") { 15 | 16 | // @TODO[Test] Improve this test 17 | // We should better demonstrate the difference between this strategy and OverflowFutureFlattenStrategy 18 | // Basically, this strategy would fail the `promise5` part of overflow strategy's spec (see below) 19 | 20 | implicit val owner: TestableOwner = new TestableOwner 21 | 22 | val effects = mutable.Buffer[Effect[Int]]() 23 | 24 | val obs = Observer[Int](effects += Effect("obs", _)) 25 | 26 | def makePromise() = Promise[Int]() 27 | 28 | def clearLogs(): Assertion = { 29 | effects.clear() 30 | assert(true) 31 | } 32 | 33 | val promise1 = makePromise() 34 | val promise2 = makePromise() 35 | val promise3 = makePromise() 36 | val promise4 = makePromise() 37 | val promise5 = makePromise() 38 | 39 | val futureBus = new EventBus[Future[Int]]() 40 | val stream = futureBus.events.flatMapSwitch(EventStream.fromFuture(_)) 41 | 42 | stream.addObserver(obs) 43 | 44 | futureBus.writer.onNext(promise1.future) 45 | futureBus.writer.onNext(promise2.future) 46 | 47 | delay { 48 | promise2.success(200) 49 | promise1.success(100) 50 | 51 | effects shouldBe mutable.Buffer() 52 | 53 | }.flatMap { _ => 54 | effects shouldBe mutable.Buffer(Effect("obs", 200)) 55 | clearLogs() 56 | 57 | promise4.success(400) 58 | 59 | effects shouldBe mutable.Buffer() 60 | 61 | }.flatMap { _ => 62 | effects shouldBe mutable.Buffer() 63 | 64 | futureBus.writer.onNext(promise3.future) 65 | futureBus.writer.onNext(promise4.future) // already resolved 66 | 67 | effects shouldBe mutable.Buffer() 68 | 69 | }.flatMap { _ => 70 | delay { 71 | effects shouldBe mutable.Buffer(Effect("obs", 400)) 72 | clearLogs() 73 | 74 | promise3.success(300) 75 | 76 | effects shouldBe mutable.Buffer() 77 | } 78 | }.flatMap { _ => 79 | futureBus.writer.onNext(promise5.future) 80 | promise5.success(500) 81 | 82 | effects shouldBe mutable.Buffer() 83 | 84 | }.flatMap { _ => 85 | delay { 86 | effects shouldBe mutable.Buffer(Effect("obs", 500)) 87 | clearLogs() 88 | } 89 | } 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/combine/SampleCombineStreamN.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.combine 2 | 3 | import com.raquo.airstream.common.{InternalParentObserver, MultiParentStream} 4 | import com.raquo.airstream.core.{EventStream, Observable, Protected, Signal, Transaction} 5 | import com.raquo.ew.JsArray 6 | 7 | import scala.scalajs.js 8 | import scala.util.Try 9 | 10 | /** This stream emits the combined value when samplingStreams emits. 11 | * 12 | * When the combined stream emits, it looks up the current value of sampledSignals, 13 | * but updates to those signals do not trigger updates to the combined stream. 14 | * 15 | * Works similar to Rx's "withLatestFrom", except without glitches (see a diamond case test for this in GlitchSpec). 16 | * 17 | * @param sampledSignals Never update this array - this stream owns it. 18 | * @param combinator Note: Must not throw! 19 | */ 20 | class SampleCombineStreamN[A, Out]( 21 | samplingStream: EventStream[A], 22 | sampledSignals: JsArray[Signal[A]], 23 | combinator: JsArray[A] => Out 24 | ) extends MultiParentStream[A, Out] with CombineObservable[Out] { 25 | 26 | override protected val topoRank: Int = Protected.maxTopoRank(samplingStream, sampledSignals) + 1 27 | 28 | private[this] var maybeLastSamplingValue: js.UndefOr[Try[A]] = js.undefined 29 | 30 | override protected[this] def inputsReady: Boolean = maybeLastSamplingValue.nonEmpty 31 | 32 | override protected[this] val parents: JsArray[Observable[A]] = { 33 | val arr = JsArray[Observable[A]](samplingStream) 34 | sampledSignals.forEach { sampledSignal => 35 | arr.push(sampledSignal) 36 | } 37 | arr 38 | } 39 | 40 | override protected[this] val parentObservers: JsArray[InternalParentObserver[_]] = { 41 | val arr = JsArray[InternalParentObserver[_]]( 42 | InternalParentObserver.fromTry[A]( 43 | samplingStream, 44 | (nextSamplingValue, trx) => { 45 | maybeLastSamplingValue = nextSamplingValue 46 | onInputsReady(trx) 47 | } 48 | ) 49 | ) 50 | sampledSignals.forEach { sampledSignal => 51 | arr.push( 52 | InternalParentObserver.fromTry[A](sampledSignal, (_, _) => { 53 | // Do nothing, we just want to ensure that sampledSignal is started. 54 | }) 55 | ) 56 | } 57 | arr 58 | } 59 | 60 | override protected[this] def combinedValue: Try[Out] = { 61 | val values = JsArray(maybeLastSamplingValue.get) 62 | sampledSignals.forEach { sampledSignal => 63 | values.push(sampledSignal.tryNow()) 64 | } 65 | CombineObservable.jsArrayCombinator(values, combinator) 66 | } 67 | 68 | override private[airstream] def syncFire(transaction: Transaction): Unit = { 69 | super.syncFire(transaction) 70 | maybeLastSamplingValue = js.undefined // Clean up memory, as we don't need this reference anymore 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/Protected.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | import com.raquo.ew.JsArray 4 | 5 | import scala.annotation.{implicitNotFound, unused} 6 | import scala.util.Try 7 | 8 | @implicitNotFound("Implicit instance of Airstream's `Protected` class not found. You're trying to access a method which is designed to only be accessed from inside a BaseObservable subtype.") 9 | class Protected private () 10 | 11 | object Protected { 12 | 13 | /** This mechanism allows us to define `protected` methods that have more lax access requirements 14 | * than Scala's `protected` keyword allows. 15 | * 16 | * Basically, if you created a custom observable subclass and inside of it you're trying to call 17 | * topoRank(), tryNow() or now() on another observable, Scala will tell you that you don't have 18 | * access to do that, but you can use one of these methods to access the required value. 19 | * 20 | * For example, instead of calling parentObservable.tryNow() you can call Protected.tryNow(parentObservable) 21 | * 22 | * The evidence is implicitly available inside BaseObservable, and so is available inside all 23 | * BaseObservable subtypes / implementations. 24 | */ 25 | private[airstream] val protectedAccessEvidence: Protected = new Protected() 26 | 27 | @inline def topoRank[O[+_] <: Observable[_]](observable: BaseObservable[O, _]): Int = { 28 | BaseObservable.topoRank(observable) 29 | } 30 | 31 | // Note: this implementation is not used in Airstream, and is provided 32 | // only for third party developers who don't want to use JsArray. 33 | def maxTopoRank[O[+_] <: Observable[_]](observables: Iterable[BaseObservable[O, _]]): Int = { 34 | observables.foldLeft(0)((maxRank, parent) => Protected.topoRank(parent) max maxRank) 35 | } 36 | 37 | @inline def maxTopoRank[O <: Observable[_]]( 38 | observables: JsArray[O] 39 | ): Int = { 40 | maxTopoRank(minRank = 0, observables) 41 | } 42 | 43 | def maxTopoRank[O <: Observable[_]]( 44 | observable: Observable[_], 45 | observables: JsArray[O] 46 | ): Int = { 47 | maxTopoRank(minRank = Protected.topoRank(observable), observables) 48 | } 49 | 50 | def maxTopoRank[O <: Observable[_]]( 51 | minRank: Int, 52 | observables: JsArray[O] 53 | ): Int = { 54 | var maxRank = minRank 55 | observables.forEach { observable => 56 | val rank = Protected.topoRank(observable) 57 | if (rank > maxRank) { 58 | maxRank = rank 59 | } 60 | } 61 | maxRank 62 | } 63 | 64 | def lastUpdateId(signal: Signal[_])(implicit @unused ev: Protected): Int = signal.lastUpdateId 65 | 66 | @inline def tryNow[A](signal: Signal[A])(implicit @unused ev: Protected): Try[A] = signal.tryNow() 67 | 68 | @inline def now[A](signal: Signal[A])(implicit @unused ev: Protected): A = signal.now() 69 | 70 | @inline def maybeWillStart[O[+_] <: Observable[_]](observable: BaseObservable[O, _]): Unit = { 71 | BaseObservable.maybeWillStart(observable) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /project/GenerateCombineSignalsTest.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | import java.io.File 4 | 5 | case class GenerateCombineSignalsTest( 6 | testSourceDir: File, 7 | from: Int, 8 | to: Int 9 | ) extends SourceGenerator( 10 | testSourceDir / "scala" / "com" / "raquo" / "airstream" / "combine" / "generated" / s"CombineSignalsSpec.scala" 11 | ) { 12 | 13 | override def apply(): Unit = { 14 | line("package com.raquo.airstream.combine.generated") 15 | line() 16 | line("import com.raquo.airstream.UnitSpec") 17 | line("import com.raquo.airstream.core.{Observer, Signal}") 18 | line("import com.raquo.airstream.fixtures.TestableOwner") 19 | line("import com.raquo.airstream.state.Var") 20 | line() 21 | line("import scala.collection.mutable") 22 | line() 23 | line("// #Warning do not edit this file directly, it is generated by GenerateCombineSignalsTest.scala") 24 | line() 25 | enter(s"class CombineSignalsSpec extends UnitSpec {", "}") { 26 | line() 27 | for (i <- 1 to to) { 28 | line(s"case class T${i}(v: Int) { def inc: T${i} = T${i}(v+1) }") 29 | } 30 | line() 31 | for (n <- from to to) { 32 | enter(s"""it("CombineSignal${n} works") {""", "}") { 33 | line() 34 | line("implicit val testOwner: TestableOwner = new TestableOwner") 35 | line() 36 | for (i <- 1 to n) { 37 | line(s"val var${i} = Var(T${i}(1))") 38 | } 39 | line() 40 | line(s"val combinedSignal = Signal.combine(${tupleType(n, "var")})") 41 | line() 42 | line(s"val effects = mutable.Buffer[(${tupleType(n)})]()") 43 | line() 44 | line(s"val observer = Observer[(${tupleType(n)})](effects += _)") 45 | line() 46 | line("// --") 47 | line() 48 | line("effects.shouldBeEmpty") 49 | line() 50 | line("// --") 51 | line() 52 | line("val subscription = combinedSignal.addObserver(observer)") 53 | line() 54 | line("// --") 55 | line() 56 | enter("effects.toList shouldBe (List(", "))") { 57 | line(s"(${(1 to n).map(i => s"T${i}(1)").mkString(", ")})") 58 | } 59 | line() 60 | line("// --") 61 | line() 62 | 63 | enter("for (iteration <- 0 until 10) {", "}") { 64 | line("effects.clear()") 65 | for (i <- 1 to n) { 66 | line(s"var${i}.update(_.inc)") 67 | } 68 | enter("effects.toList shouldBe (", ")") { 69 | enter("List(", ")") { 70 | for (i <- 1 to n) { 71 | line(s"(${(1 to n).map(j => s"T${j}(1 + iteration${if (j <= i) " + 1" else ""})").mkString(", ")})${if (i < n) "," else ""}") 72 | } 73 | } 74 | } 75 | } 76 | line() 77 | line("subscription.kill()") 78 | 79 | } 80 | line() 81 | } 82 | line() 83 | } 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/core/InternalObserver.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.core 2 | 3 | import scala.util.{Failure, Success, Try} 4 | 5 | trait InternalObserver[-A] { 6 | 7 | /** Must not throw */ 8 | protected def onNext(nextValue: A, transaction: Transaction): Unit 9 | 10 | /** Must not throw */ 11 | protected def onError(nextError: Throwable, transaction: Transaction): Unit 12 | 13 | /** Must not throw */ 14 | protected def onTry(nextValue: Try[A], transaction: Transaction): Unit 15 | } 16 | 17 | object InternalObserver { 18 | 19 | val empty: InternalObserver[Any] = new InternalObserver[Any] { 20 | 21 | override protected def onNext(nextValue: Any, transaction: Transaction): Unit = () 22 | 23 | override protected def onError(nextError: Throwable, transaction: Transaction): Unit = () 24 | 25 | override protected def onTry(nextValue: Try[Any], transaction: Transaction): Unit = () 26 | } 27 | 28 | def apply[A]( 29 | onNext: (A, Transaction) => Unit, 30 | onError: (Throwable, Transaction) => Unit 31 | ): InternalObserver[A] = { 32 | val onNextParam = onNext // It's beautiful on the outside 33 | val onErrorParam = onError 34 | 35 | new InternalObserver[A] { 36 | 37 | final override def onNext(nextValue: A, transaction: Transaction): Unit = { 38 | onNextParam(nextValue, transaction) 39 | } 40 | 41 | final override def onError(nextError: Throwable, transaction: Transaction): Unit = { 42 | onErrorParam(nextError, transaction) 43 | } 44 | 45 | final override def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 46 | nextValue.fold(onError(_, transaction), onNext(_, transaction)) 47 | } 48 | } 49 | } 50 | 51 | def fromTry[A](onTry: (Try[A], Transaction) => Unit): InternalObserver[A] = { 52 | val onTryParam = onTry // It's beautiful on the outside 53 | 54 | new InternalObserver[A] { 55 | 56 | final override def onNext(nextValue: A, transaction: Transaction): Unit = { 57 | onTry(Success(nextValue), transaction) 58 | } 59 | 60 | final override def onError(nextError: Throwable, transaction: Transaction): Unit = { 61 | onTry(Failure(nextError), transaction) 62 | } 63 | 64 | final override def onTry(nextValue: Try[A], transaction: Transaction): Unit = { 65 | onTryParam(nextValue, transaction) 66 | } 67 | } 68 | } 69 | 70 | @inline private[airstream] def onNext[A]( 71 | observer: InternalObserver[A], 72 | nextValue: A, 73 | transaction: Transaction 74 | ): Unit = { 75 | observer.onNext(nextValue, transaction) 76 | } 77 | 78 | @inline private[airstream] def onError( 79 | observer: InternalObserver[_], 80 | nextError: Throwable, 81 | transaction: Transaction 82 | ): Unit = { 83 | observer.onError(nextError, transaction) 84 | } 85 | 86 | @inline private[airstream] def onTry[A]( 87 | observer: InternalObserver[A], 88 | nextValue: Try[A], 89 | transaction: Transaction 90 | ): Unit = { 91 | observer.onTry(nextValue, transaction) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/ownership/DynamicOwnerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.ownership 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.core.Observer 5 | import com.raquo.airstream.eventbus.EventBus 6 | import com.raquo.airstream.fixtures.Effect 7 | import com.raquo.airstream.state.Var 8 | 9 | import scala.collection.mutable 10 | 11 | class DynamicOwnerSpec extends UnitSpec { 12 | 13 | it("Dynamic owner activation and deactivation") { 14 | 15 | val bus1 = new EventBus[Int] 16 | val var2 = Var(0) 17 | 18 | val effects = mutable.Buffer[Effect[Int]]() 19 | 20 | val obs1 = Observer[Int](effects += Effect("obs1", _)) 21 | val obs2 = Observer[Int](effects += Effect("obs2", _)) 22 | 23 | val dynOwner = new DynamicOwner(() => fail("Attempted to use permakilled owner!")) 24 | 25 | DynamicSubscription.unsafe(dynOwner, owner => bus1.events.addObserver(obs1)(owner)) 26 | 27 | bus1.writer.onNext(100) 28 | 29 | effects shouldBe mutable.Buffer() 30 | dynOwner.isActive shouldBe false 31 | dynOwner.maybeCurrentOwner shouldBe None 32 | 33 | // -- 34 | 35 | dynOwner.activate() 36 | 37 | effects shouldBe mutable.Buffer() 38 | dynOwner.isActive shouldBe true 39 | 40 | // -- 41 | 42 | bus1.writer.onNext(200) 43 | effects shouldBe mutable.Buffer(Effect("obs1", 200)) 44 | effects.clear() 45 | 46 | // -- 47 | 48 | val dynSub2 = DynamicSubscription.subscribeObserver(dynOwner, var2.signal, obs2) 49 | effects shouldBe mutable.Buffer(Effect("obs2", 0)) 50 | effects.clear() 51 | 52 | // -- 53 | 54 | dynOwner.deactivate() 55 | bus1.writer.onNext(300) 56 | var2.writer.onNext(3) 57 | 58 | effects shouldBe mutable.Buffer() 59 | 60 | // -- 61 | 62 | dynOwner.activate() // this subscribes to the signal. It remembers 3 despite deactivation because Var is a StrictSignal. Not the best test I guess. 63 | bus1.writer.onNext(400) 64 | var2.writer.onNext(4) 65 | 66 | effects shouldBe mutable.Buffer(Effect("obs2", 3), Effect("obs1", 400), Effect("obs2", 4)) 67 | effects.clear() 68 | 69 | // -- 70 | 71 | bus1.writer.onNext(500) 72 | var2.writer.onNext(5) 73 | 74 | effects shouldBe mutable.Buffer(Effect("obs1", 500), Effect("obs2", 5)) 75 | effects.clear() 76 | 77 | // -- 78 | 79 | dynSub2.kill() // permanently deactivated and removed from owner 80 | bus1.writer.onNext(600) 81 | var2.writer.onNext(6) 82 | 83 | effects shouldBe mutable.Buffer(Effect("obs1", 600)) 84 | effects.clear() 85 | 86 | // -- 87 | 88 | dynOwner.deactivate() 89 | bus1.writer.onNext(700) 90 | var2.writer.onNext(7) 91 | 92 | effects shouldBe mutable.Buffer() 93 | 94 | // -- 95 | 96 | dynOwner.activate() 97 | 98 | effects shouldBe mutable.Buffer() 99 | 100 | // -- 101 | 102 | bus1.writer.onNext(800) 103 | var2.writer.onNext(8) 104 | 105 | effects shouldBe mutable.Buffer(Effect("obs1", 800)) 106 | effects.clear() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/scala/com/raquo/airstream/common/SingleParentSignal.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.common 2 | 3 | import com.raquo.airstream.core.{Observable, Protected, Signal, Transaction, WritableSignal} 4 | 5 | import scala.util.Try 6 | 7 | /** A simple stream that only has one parent. */ 8 | trait SingleParentSignal[I, O] extends WritableSignal[O] with InternalTryObserver[I] { 9 | 10 | protected[this] val parent: Observable[I] 11 | 12 | protected[this] val parentIsSignal: Boolean = parent.isInstanceOf[Signal[_]] 13 | 14 | // Note: `-1` here means that we've never synced up with the parent. 15 | // I am not sure if -1 vs 0 has a practical difference, but looking 16 | // at our onWillStart code, it seems that using -1 here would be more 17 | // prudent. If using 0, the initial onWillStart may not detect the 18 | // "change" (from no value to parent signal's initial value), and the 19 | // signal's value would only be updated in tryNow(). 20 | protected[this] var _parentLastUpdateId: Int = -1 21 | 22 | /** Note: this is overriden in: 23 | * - [[com.raquo.airstream.misc.SignalFromStream]] because parent can be stream, and it has cacheInitialValue logic 24 | * - [[com.raquo.airstream.split.SplitChildSignal]] because its parent is a special timing stream, not the real parent 25 | */ 26 | override protected def onWillStart(): Unit = { 27 | // dom.console.log(s"${this} > onWillStart (SPS)") 28 | Protected.maybeWillStart(parent) 29 | if (parentIsSignal) { 30 | val newParentLastUpdateId = Protected.lastUpdateId(parent.asInstanceOf[Signal[_]]) 31 | if (newParentLastUpdateId != _parentLastUpdateId) { 32 | updateCurrentValueFromParent( 33 | currentValueFromParent(), 34 | newParentLastUpdateId 35 | ) 36 | } 37 | } 38 | } 39 | 40 | /** Note: this is overridden in: 41 | * - [[com.raquo.airstream.split.SplitChildSignal]] to clear cached initial value (if any) 42 | * - [[com.raquo.airstream.distinct.DistinctSignal]] to filter out isSame events 43 | */ 44 | protected def updateCurrentValueFromParent( 45 | nextValue: Try[O], 46 | nextParentLastUpdateId: Int 47 | ): Unit = { 48 | // dom.console.log(s"${this} > updateCurrentValueFromParent") 49 | setCurrentValue(nextValue) 50 | _parentLastUpdateId = nextParentLastUpdateId 51 | } 52 | 53 | /** Note: this is overridden in: 54 | * - [[com.raquo.airstream.split.SplitChildSignal]] because its parent is a special timing stream, not the real parent 55 | */ 56 | override protected def onTry(nextParentValue: Try[I], transaction: Transaction): Unit = { 57 | if (parentIsSignal) { 58 | _parentLastUpdateId = Protected.lastUpdateId(parent.asInstanceOf[Signal[_]]) 59 | } 60 | } 61 | 62 | override protected[this] def onStart(): Unit = { 63 | // println(s"${this} > onStart") 64 | parent.addInternalObserver(this, shouldCallMaybeWillStart = false) 65 | super.onStart() 66 | } 67 | 68 | override protected[this] def onStop(): Unit = { 69 | parent.removeInternalObserver(observer = this) 70 | super.onStop() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/combine/MergeStreamSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.combine 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.core.{EventStream, Observer} 5 | import com.raquo.airstream.eventbus.EventBus 6 | import com.raquo.airstream.fixtures.{Calculation, TestableOwner} 7 | 8 | import scala.collection.mutable 9 | 10 | class MergeStreamSpec extends UnitSpec { 11 | 12 | it("order of events follows toporank") { 13 | 14 | val calculations = mutable.Buffer[Calculation[Int]]() 15 | 16 | val owner = new TestableOwner 17 | 18 | val bus = new EventBus[Int] 19 | val tens = bus.events.map(_ * 10) 20 | val hundreds = tens.map(_ * 10) 21 | 22 | val sub1 = EventStream.merge(tens, hundreds) 23 | .map(Calculation.log("merged", calculations)) 24 | .addObserver(Observer.empty)(owner) 25 | 26 | // -- 27 | 28 | bus.emit(1) 29 | 30 | calculations.toList shouldBe List( 31 | Calculation("merged", 10), 32 | Calculation("merged", 100), 33 | ) 34 | 35 | calculations.clear() 36 | 37 | // -- 38 | 39 | bus.emit(2) 40 | 41 | calculations.toList shouldBe List( 42 | Calculation("merged", 20), 43 | Calculation("merged", 200), 44 | ) 45 | 46 | calculations.clear() 47 | 48 | // -- 49 | 50 | sub1.kill() 51 | 52 | // -- Order of events is the same (based on topoRank) even if order of observables is reversed 53 | 54 | val sub2 = EventStream.merge(hundreds, tens) 55 | .map(Calculation.log("merged", calculations)) 56 | .addObserver(Observer.empty)(owner) 57 | 58 | // -- 59 | 60 | bus.emit(1) 61 | 62 | calculations.toList shouldBe List( 63 | Calculation("merged", 10), 64 | Calculation("merged", 100), 65 | ) 66 | 67 | calculations.clear() 68 | } 69 | 70 | it("good behaviour when paired with combineWith") { 71 | 72 | // if combineWith's parent observable emits multiple events 73 | // in the same transaction (violating the transaction contract), 74 | // combineWith swallows all but the last event, so it is good 75 | // at exposing when parent observables emit more than once 76 | // in the same transaction. 77 | 78 | val calculations = mutable.Buffer[Calculation[Int]]() 79 | 80 | val owner = new TestableOwner 81 | 82 | val bus = new EventBus[Int] 83 | val tens = bus.events.map(_ * 10) 84 | val hundreds = tens.map(_ * 10) 85 | 86 | val sub1 = bus.events 87 | .combineWithFn(EventStream.merge(tens, hundreds))(_ + _) 88 | .map(Calculation.log("combined", calculations)) 89 | .addObserver(Observer.empty)(owner) 90 | 91 | // -- 92 | 93 | bus.emit(1) 94 | 95 | calculations.toList shouldBe List( 96 | Calculation("combined", 11), 97 | Calculation("combined", 101), 98 | ) 99 | 100 | calculations.clear() 101 | 102 | // -- 103 | 104 | bus.emit(2) 105 | 106 | calculations.toList shouldBe List( 107 | Calculation("combined", 22), 108 | Calculation("combined", 202), 109 | ) 110 | 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/test/scala/com/raquo/airstream/extensions/OptionObservableSpec.scala: -------------------------------------------------------------------------------- 1 | package com.raquo.airstream.extensions 2 | 3 | import com.raquo.airstream.UnitSpec 4 | import com.raquo.airstream.eventbus.EventBus 5 | import com.raquo.airstream.fixtures.{Effect, TestableOwner} 6 | import com.raquo.airstream.ownership.Owner 7 | 8 | import scala.collection.mutable 9 | 10 | class OptionObservableSpec extends UnitSpec { 11 | 12 | it("OptionObservable: mapSome") { 13 | 14 | implicit val owner: Owner = new TestableOwner 15 | 16 | val bus = new EventBus[Option[Int]] 17 | 18 | val effects = mutable.Buffer[Effect[_]]() 19 | bus 20 | .events 21 | .mapSome(_ * 10) 22 | .foreach(v => effects += Effect("obs", v)) 23 | 24 | effects shouldBe mutable.Buffer() 25 | 26 | // -- 27 | 28 | bus.emit(Some(1)) 29 | 30 | effects shouldBe mutable.Buffer( 31 | Effect("obs", Some(10)) 32 | ) 33 | effects.clear() 34 | 35 | // -- 36 | 37 | bus.emit(None) 38 | 39 | effects shouldBe mutable.Buffer( 40 | Effect("obs", None) 41 | ) 42 | effects.clear() 43 | 44 | // -- 45 | 46 | bus.emit(Some(2)) 47 | 48 | effects shouldBe mutable.Buffer( 49 | Effect("obs", Some(20)) 50 | ) 51 | effects.clear() 52 | 53 | } 54 | 55 | it("OptionStream: collectSome") { 56 | 57 | implicit val owner: Owner = new TestableOwner 58 | 59 | val bus = new EventBus[Option[Int]] 60 | 61 | val effects = mutable.Buffer[Effect[_]]() 62 | bus 63 | .events 64 | .collectSome 65 | .foreach(v => effects += Effect("obs", v)) 66 | 67 | effects shouldBe mutable.Buffer() 68 | 69 | // -- 70 | 71 | bus.emit(Some(1)) 72 | 73 | effects shouldBe mutable.Buffer( 74 | Effect("obs", 1) 75 | ) 76 | effects.clear() 77 | 78 | // -- 79 | 80 | bus.emit(Some(2)) 81 | 82 | effects shouldBe mutable.Buffer( 83 | Effect("obs", 2) 84 | ) 85 | effects.clear() 86 | 87 | // -- 88 | 89 | bus.emit(None) 90 | 91 | effects shouldBe mutable.Buffer() 92 | 93 | // -- 94 | 95 | bus.emit(Some(3)) 96 | 97 | effects shouldBe mutable.Buffer( 98 | Effect("obs", 3) 99 | ) 100 | effects.clear() 101 | 102 | } 103 | 104 | it("OptionStream: collectSome { ... }") { 105 | 106 | implicit val owner: Owner = new TestableOwner 107 | 108 | val bus = new EventBus[Option[Int]] 109 | 110 | val effects = mutable.Buffer[Effect[_]]() 111 | bus 112 | .events 113 | .collectSome { case x if x % 2 == 0 => x } 114 | .foreach(v => effects += Effect("obs", v)) 115 | 116 | effects shouldBe mutable.Buffer() 117 | 118 | // -- 119 | 120 | bus.emit(Some(1)) 121 | 122 | effects.shouldBeEmpty 123 | 124 | // -- 125 | 126 | bus.emit(Some(2)) 127 | 128 | effects shouldBe mutable.Buffer( 129 | Effect("obs", 2) 130 | ) 131 | effects.clear() 132 | 133 | // -- 134 | 135 | bus.emit(None) 136 | bus.emit(Some(3)) 137 | 138 | effects shouldBe mutable.Buffer() 139 | } 140 | } 141 | --------------------------------------------------------------------------------