├── .editorconfig ├── .gitignore ├── .npmrc.template ├── CHANGELOG.md ├── LICENCE.md ├── Makefile ├── README.md ├── logo.png └── packages ├── bulb-input ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── rollup.config.js └── src │ ├── index.js │ ├── internal │ └── emitter.js │ ├── keyboard │ ├── keys.js │ ├── keys.test.js │ ├── state.js │ └── state.test.js │ └── mouse │ ├── buttons.js │ ├── buttons.test.js │ ├── position.js │ ├── position.test.js │ ├── state.js │ └── state.test.js └── bulb ├── README.md ├── babel.config.js ├── bulb.d.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── Bus.js ├── Bus.test.js ├── Signal.js ├── Signal.test.js ├── Subscription.js ├── combinators ├── all.js ├── all.test.js ├── always.js ├── always.test.js ├── any.js ├── any.test.js ├── apply.js ├── apply.test.js ├── buffer.js ├── buffer.test.js ├── bufferWith.js ├── bufferWith.test.js ├── catchError.js ├── catchError.test.js ├── concat.js ├── concat.test.js ├── concatMap.js ├── concatMap.test.js ├── cycle.js ├── cycle.test.js ├── debounce.js ├── debounce.test.js ├── dedupeWith.js ├── dedupeWith.test.js ├── delay.js ├── delay.test.js ├── drop.js ├── drop.test.js ├── dropUntil.js ├── dropUntil.test.js ├── dropWhile.js ├── dropWhile.test.js ├── filter.js ├── filter.test.js ├── fold.js ├── fold.test.js ├── hold.js ├── hold.test.js ├── map.js ├── map.test.js ├── merge.js ├── merge.test.js ├── sample.js ├── sample.test.js ├── scan.js ├── scan.test.js ├── sequential.js ├── sequential.test.js ├── stateMachine.js ├── stateMachine.test.js ├── switchMap.js ├── switchMap.test.js ├── take.js ├── take.test.js ├── takeUntil.js ├── takeUntil.test.js ├── takeWhile.js ├── takeWhile.test.js ├── tap.js ├── tap.test.js ├── throttle.js ├── throttle.test.js ├── window.js ├── window.test.js ├── zipLatestWith.js ├── zipLatestWith.test.js ├── zipWith.js └── zipWith.test.js ├── index.js ├── internal └── mockSignal.js └── scheduler.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs 3 | node_modules 4 | -------------------------------------------------------------------------------- /.npmrc.template: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 7.0.0 (2021-03-25) 2 | 3 | * Add `Signal#bufferWith` method 4 | * Fix `Signal.zip` method 5 | * Add window combinator 6 | * Add zipLatestWith combinator 7 | * Fix `Signal.merge` function 8 | * Only complete `zipWith` signal after all signals have completed 9 | * Allow `withCallback` to handle an unmount function 10 | * Fix concat combinator 11 | * Fix take combinator 12 | * Fix takeWhile combinator 13 | * Fix `Signal.concat` function 14 | * Add typescript types 15 | 16 | ## 6.1.0 (2019-03-25) 17 | 18 | * Add `Signal#tap` method 19 | 20 | ## 6.0.0 (2019-03-23) 21 | 22 | * Refactor combinators to fix cyclical dependency warnings 23 | * Add dev dependency on Fkit 24 | * Export classes in separate files to help with bundle sizes 25 | 26 | ## 5.0.0 (2019-02-09) 27 | 28 | * Rename `value` -> `next` to match the [observable proposal](https://github.com/tc39/proposal-observable) 29 | * Remove runtime dependency on [Fkit](https://github.com/nullobject/fkit) 30 | * Mark subscriptions as closed when an observer unsubscribes 31 | * Allow `Signal.of` to take multiple arguments 32 | * Don't allow functions which take multiple arguments to an array (use the array spread syntax instead) 33 | * Rename fromArray -> from 34 | 35 | ## 4.0.1 (2019-02-04) 36 | 37 | * Fix peer dependencies for bulb-input 38 | 39 | ## 4.0.0 (2019-02-04) 40 | 41 | * Add `Bus` class 42 | 43 | ## 3.1.0 (2019-01-27) 44 | 45 | * Add `Signal#all` method 46 | * Add `Signal#any` method 47 | 48 | ## 3.0.2 (2019-01-26) 49 | 50 | * Fix issue with array arguments passed to `startWith`, `endWith`, `append`, and `prepend` methods 51 | 52 | ## 3.0.1 (2019-01-26) 53 | 54 | * Add `Signal#startWith` and `Signal#endWith` methods 55 | 56 | ## 3.0.0 (2019-01-26) 57 | 58 | * Remove `Signal.sequential` static method 59 | * Change `Signal.periodic` to emit sequential numbers 60 | * Add `Signal#first` method 61 | * Add `Signal#last` method 62 | * Add index to `map`, `filter`, `fold`, and `scan` methods 63 | * Add `Signal.merge` static method 64 | * Add `Signal.zip` static method 65 | * Add `Signal.zipWith` static method 66 | * Remove combinator functions from exports 67 | * Extract keyboard and mouse signals into `bulb-input` package 68 | 69 | ## 2.2.0 (2019-01-17) 70 | 71 | * Add `apply` function 72 | * Add `Signal.throwError` static method 73 | * Add `catchError` function 74 | * Add `Signal#catchError` method 75 | 76 | ## 2.1.0 (2019-01-12) 77 | 78 | * Add `buffer` function 79 | * Add `Signal#buffer` method 80 | 81 | ## 2.0.0 (2019-01-12) 82 | 83 | * Add `switchMap` function 84 | * Add `Signal#switchMap` method 85 | * Fix an issue where `concatMap` wouldn't wait for signals to complete 86 | * Add missing combinators to exports 87 | * Add `concat` function 88 | * Add `Signal#concat` method 89 | * Add `always` function 90 | * Add `cycle` function 91 | * Add `sequential` function 92 | * Add `prepend` function 93 | * Add `append` function 94 | * Add `Signal#prepend` method 95 | * Add `Signal#append` method 96 | * Fix an issue where exceptions were being swallowed when mounting 97 | * Add `takeUntil` function 98 | * Add `dropUntil` function 99 | * Add `Signal#takeUntil` method 100 | * Add `Signal#dropUntil` method 101 | * Reorder the arguments for `Signal#sample` and `Signal#hold` 102 | 103 | ## 1.4.0 (2019-01-08) 104 | 105 | * Add `Signal#cycle` method 106 | * Add `Signal#sequential` method 107 | * Deprecate `Signal.sequential` method (use `Signal.periodic(1000).sequential([1, 2, 3])` instead) 108 | * Remove `setTimeout` in `Signal.fromArray` method 109 | * Remove `setTimeout` in `scan` function 110 | * Unmount signals when they have completed 111 | * Add `take` function 112 | * Add `takeWhile` function 113 | * Add `drop` function 114 | * Add `dropWhile` function 115 | * Add `Signal#take` method 116 | * Add `Signal#takeWhile` method 117 | * Add `Signal#drop` method 118 | * Add `Signal#dropWhile` method 119 | 120 | ## 1.3.0 (2019-01-06) 121 | 122 | * Rename `emit.next` -> `emit.value` 123 | * Move keyboard and mouse methods to separate functions (e.g. `keyboardKeys`, `keyboardState`, `mouseButtons`, `mousePosition`, and `mouseState`) 124 | * Extract all functions to separate files 125 | * Change to documentation.js for API docs 126 | 127 | ## 1.2.0 (2018-12-30) 128 | 129 | * Switch to jest for tests 130 | * Update readme 131 | * Fix issue where some signals weren't being unmounted properly 132 | 133 | ## 1.1.1 (2018-01-24) 134 | 135 | * Fix issue with initial value scheduling 136 | * Add watch task 137 | * Fix param documentation for curried functions 138 | * Update readme 139 | * Add book search example 140 | 141 | ## 1.1.0 (2018-01-12) 142 | 143 | * Fix an issue with Signal.fromEvent 144 | * Update documentation 145 | * Rename bulb.js -> index.js 146 | * Rename combinator -> combinators 147 | 148 | ## 1.0.0 (2018-01-02) 149 | 150 | * Update rollup to 0.53.2 151 | * Update jsdoc-react to 1.0.0 152 | * Update fkit to 1.1.0 153 | * Update mocha to 4.1.0 154 | * Update documentation 155 | * Refactor keyboard and mouse modules 156 | * Add Signal#throttle and Signal#debounce 157 | * Add throttle function 158 | * Add debounce function 159 | * Curry the combinator functions 160 | * Extract combinators into modules 161 | * Update zipWith function to buffer values 162 | * Rename license to licence 163 | * Remove sampleWith and holdWith functions 164 | * Ensure signal combinators are unsubscribed 165 | * Add Signal#encode 166 | * Add Signal#switch 167 | * Allow Signal#merge to receieve signals as an array 168 | * Update copyright in license 169 | * Don't export default from main module 170 | * Rename observer -> emit 171 | * Refactor subscribe calls to use object reset spread 172 | 173 | ## 0.4.0 (2017-12-18) 174 | 175 | * Fix rollup packaging 176 | * Allow merge to take many arguments 177 | * Refactor stateMachine to take observer argument 178 | 179 | ## 0.3.2 (2017-12-17) 180 | 181 | * Store keyboard state in a set 182 | * Add Signal#startWith function 183 | 184 | ## 0.3.1 (2017-12-16) 185 | 186 | * Don't generate duplicate keyboard events 187 | * Handle no transform function result in Signal#stateMachine 188 | 189 | ## 0.3.0 (2017-12-16) 190 | 191 | * Add Signal#stateMachine function 192 | 193 | ## 0.2.0 (2017-12-16) 194 | 195 | * First cut of the API 196 | 197 | ## 0.1.0 (2014-11-06) 198 | 199 | * Initial import 200 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | The MIT Licence (MIT) 2 | 3 | Copyright (c) 2018 Josh Bassett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean dev dist doc lint node_modules publish-api publish-npm release test watch 2 | 3 | node_modules: 4 | @cd packages/bulb; npm install 5 | @cd packages/bulb-input; npm install 6 | 7 | dev: 8 | @cd packages/bulb; npx rollup -c -w 9 | 10 | dist: 11 | @rm -rf packages/bulb/dist packages/bulb-input/dist 12 | @cd packages/bulb; npx rollup -c 13 | @cd packages/bulb-input; npx rollup -c 14 | 15 | test: 16 | @cd packages/bulb; npx jest 17 | 18 | watch: 19 | @cd packages/bulb; npx jest --watch 20 | 21 | lint: 22 | @cd packages/bulb; npx standard "src/**/*.js" 23 | @cd packages/bulb-input; npx standard "src/**/*.js" 24 | 25 | release: dist doc publish-api publish-npm 26 | 27 | doc: 28 | @cd packages/bulb; npx documentation build src/** -f html -o docs 29 | 30 | publish-api: 31 | @aws s3 sync ./packages/bulb/docs/ s3://bulb.joshbassett.info/ --acl public-read --delete --cache-control 'max-age=300' 32 | 33 | publish-npm: 34 | @cd packages/bulb; npm publish 35 | @cd packages/bulb-input; npm publish 36 | 37 | clean: 38 | @rm -rf packages/bulb/dist packages/bulb-input/dist docs 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Bulb

2 | 3 | [![Build Status](https://travis-ci.com/nullobject/bulb.svg?branch=master)](https://travis-ci.com/nullobject/bulb) 4 | 5 | Bulb is a [reactive 6 | programming](https://en.wikipedia.org/wiki/Reactive_programming) library for 7 | JavaScript. It provides a simple API for writing event-based programs in a 8 | declarative style. 9 | 10 | The main data structure introduced by Bulb is called a *signal*. A signal 11 | represents a time-varying source of values — for example, the value of a 12 | text input, a periodic timer, or the position of the mouse pointer in the 13 | browser window. 14 | 15 | The Bulb API provides many functions for creating signals from various sources 16 | (e.g. arrays, timers, AJAX requests, DOM events, etc.) and for modifying 17 | signals using *combinators*. 18 | 19 | A number of libraries already exist for reactive programming in JavaScript 20 | (e.g. RxJS, Bacon.js, Most.js), but Bulb differs in that it tries to avoid 21 | providing a "kitchen sink". Instead, Bulb defines a very focussed API that 22 | contains only the key building blocks for reactive programming in JavaScript. 23 | 24 | Features: 25 | 26 | * Implements the [ECMAScript Observables 27 | proposal](https://github.com/tc39/proposal-observable). 28 | * Simple, focused API. Bigger isn't always better. 29 | * It's small, roughly 4 KB when minified and gzipped. 30 | 31 | ## Table of Contents 32 | 33 | * [Installation](#installation) 34 | * [Node](#node) 35 | * [Browser](#browser) 36 | * [Documentation](#documentation) 37 | * [What is a Signal?](#what-is-a-signal-traffic_light) 38 | * [Signals at a Glance](#signals-at-a-glance-eyes) 39 | * [Combinators](#combinators-revolving_hearts) 40 | * [The Signal Life Cycle](#the-signal-life-cycle-recycle) 41 | * [Get on the Bus](#get-on-the-bus-bus) 42 | * [Examples](#examples) 43 | * [Licence](#licence) 44 | 45 | ## Installation 46 | 47 | ### Node 48 | 49 | Install the npm package: 50 | 51 | ```sh 52 | > npm install bulb 53 | ``` 54 | 55 | Require it in your code: 56 | 57 | ```js 58 | import { Signal } from 'bulb' 59 | ``` 60 | 61 | ### Browser 62 | 63 | The easiest way to start using Bulb in your browser is to include it with a 64 | ` 68 | ``` 69 | 70 | ## Documentation 71 | 72 | * [API documentation](http://bulb.joshbassett.info) 73 | * Article by Josh Bassett: [Bulb: A Reactive Programming Library for JavaScript](https://joshbassett.info/2018/bulb/) 74 | 75 | ### What is a Signal? :traffic_light: 76 | 77 | The term *signal* is borrowed from hardware description languages, which allow 78 | electrical signals to be modelled as they travel through circuits. Much like in 79 | circuits, signals represent a time-varying flow of data – they can be split 80 | apart, joined together, and modified as they travel through a system. 81 | 82 | Signals are: 83 | 84 | * *Directed*: Data travels through a network of signals in only one direction. 85 | * *Composable*: Signals can be composed together to create new signals. 86 | * *Lazy*: Signals don't do anything until they actually need to (i.e. an 87 | *observer* has subscribed). 88 | 89 | ### Signals at a Glance :eyes: 90 | 91 | Let's create a simple signal that emits some values and logs them to the 92 | console: 93 | 94 | ```js 95 | import { Signal } from 'bulb' 96 | 97 | const s = Signal.of(1, 2, 3) 98 | 99 | s.subscribe({ 100 | next (a) { console.log(a) } 101 | }) 102 | ``` 103 | 104 | Here, `Signal.of(1, 2, 3)` creates a new signal which emits some values in 105 | order. At this point, the signal won't actually do anything until an *observer* 106 | subscribes to the signal. 107 | 108 | The `subscribe` method subscribes an observer to the signal. This means that 109 | the `next` callback will be called when the signal emits a value. In this case, 110 | it just prints the emitted values to the console. 111 | 112 | An observer can also specify other callback types: 113 | 114 | * `next`: Called when the signal emits a value. 115 | * `error`: Called when the signal emits an error. 116 | * `complete`: Called when the signal has finished emitting events. 117 | 118 | There is also a handy shortcut if you only want to know when the signal emits a 119 | value. In this case, you can just pass a single callback instead of an observer 120 | object: 121 | 122 | ```js 123 | s.subscribe(a => console.log(a)) 124 | ``` 125 | 126 | ### Combinators :revolving_hearts: 127 | 128 | Let's continue with our signal from the previous example, but use the `map` 129 | combinator to modify the values before they are logged to the console: 130 | 131 | ```js 132 | import { Signal } from 'bulb' 133 | 134 | const s = Signal.of(1, 2, 3) 135 | const t = s.map(a => a + 1) 136 | 137 | t.subscribe(console.log) // 2, 3, 4 138 | ``` 139 | 140 | In this example, we created a completely new signal `t`, by mapping a function 141 | over the original signal `s`. When we subscribe to the new signal `t`, the 142 | modified values are printed in the console. 143 | 144 | Another useful combinator is `scan`: 145 | 146 | ```js 147 | import { Signal } from 'bulb' 148 | 149 | const s = Signal.of(1, 2, 3) 150 | const t = s.scan((a, b) => a + b, 0) 151 | 152 | t.subscribe(console.log) // 0, 1, 3, 6 153 | ``` 154 | 155 | In this example we created a signal `t`, that takes the values emitted by the 156 | signal `s` and emits the running total of the values, starting from zero. The 157 | function `(a, b) => a + b` is called for every value emitted by the signal `s`, 158 | where `a` is the accumulated value, and `b` is the emitted value. Note that the 159 | `scan` combinator will emit the accumulated value for *every* value emitted by 160 | the signal `s`. 161 | 162 | Some combinators wait until the signal has completed before they emit a value. 163 | The `fold` combinator is similar to the `scan` combinator, but it differs in 164 | that it doesn't emit intermediate values. The final value will only be emitted 165 | after the signal has completed: 166 | 167 | ```js 168 | import { Signal } from 'bulb' 169 | 170 | const s = Signal.of(1, 2, 3) 171 | const t = s.fold((a, b) => a + b, 0) 172 | 173 | t.subscribe(console.log) // 6 174 | ``` 175 | 176 | In this example, we created a signal `t`, that takes the values emitted by the 177 | signal `s` and calculates the total of the emitted values, starting from zero. 178 | The function `(a, b) => a + b` is called for every value emitted by the signal 179 | `s`, where `a` is the accumulated value, and `b` is the emitted value. Note 180 | that the `scan` combinator will only emit the accumulated value once the signal 181 | `s` has completed. 182 | 183 | ### The Signal Life Cycle :recycle: 184 | 185 | As we saw previously, to subscribe an observer to a signal we use the 186 | `subscribe` method. This method returns a subscription handle, which we can use 187 | to unsubscribe from the signal at a later point in time. 188 | 189 | This can be useful for dealing with infinite signals (infinite signals are 190 | signals which never complete, they just keep emitting values forever): 191 | 192 | ```js 193 | import { Signal } from 'bulb' 194 | 195 | const s = Signal.periodic(1000) 196 | const subscription = s.subscribe(console.log) // 0, 1, 2, ... 197 | 198 | // Some time later... 199 | subscription.unsubscribe() 200 | ``` 201 | 202 | In this example, we called the `periodic` method to create a signal `s` that 203 | emits an increasing number every second. When we subscribe to the signal, we 204 | keep a reference to the returned subscription handle. To stop receiving values 205 | from the signal, we call the `unsubscribe` method on the handle. 206 | 207 | ### Get on the Bus :bus: 208 | 209 | A `Bus` is a special type of signal that can be connected with other signals: 210 | 211 | ```js 212 | import { Bus, Signal } from 'bulb' 213 | 214 | const s = Signal.of(1, 2, 3) 215 | const bus = new Bus() 216 | const subscription = bus.subscribe(console.log) 217 | 218 | bus.connect(s) 219 | ``` 220 | 221 | In this example, we created a bus and a signal. We connected the signal `s` to 222 | the bus by calling the `connect` method, which means that any values emitted by 223 | the signal `s` will be re-emitted by the bus. 224 | 225 | Sometimes it is useful to manually emit values on a bus: 226 | 227 | ```js 228 | import { Bus } from 'bulb' 229 | 230 | const bus = new Bus() 231 | const subscription = bus.subscribe(console.log) 232 | 233 | bus.next(1) 234 | bus.next(2) 235 | bus.next(3) 236 | ``` 237 | 238 | In this example, we created a bus and subscribed it to the console logger. We 239 | then manually emitted some values by calling the `next` method on the bus. 240 | 241 | ## Examples 242 | 243 | Take a look at some examples of how to use Bulb in the real world: 244 | 245 | * [React](https://codepen.io/nullobject/pen/LqdERw) 246 | * [Timer](https://codepen.io/nullobject/pen/wpjQoM) 247 | * [Mouse Position](https://codepen.io/nullobject/pen/eyGQdY) 248 | * [Keyboard State](https://codepen.io/nullobject/pen/qpYoMw) 249 | * [Book Search](https://codepen.io/nullobject/pen/QarojE) 250 | * [Random Strings](https://codepen.io/nullobject/pen/rpvaeg) 251 | * [PIN Pad](https://codepen.io/nullobject/pen/jYxzda) 252 | 253 | ## Licence 254 | 255 | Bulb is licensed under the MIT licence. See the 256 | [LICENCE](https://github.com/nullobject/bulb/blob/master/LICENCE.md) file for 257 | more details. 258 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nullobject/bulb/3d3e66649b580a0f98c16e3eb3e712ccf83c8051/logo.png -------------------------------------------------------------------------------- /packages/bulb-input/README.md: -------------------------------------------------------------------------------- 1 | # Bulb Input 2 | 3 | This package extends [Bulb](https://github.com/nullobject/bulb) to provide 4 | signals for working with the keyboard and mouse input devices. 5 | 6 | ## Installation 7 | 8 | ```sh 9 | > npm install bulb bulb-input 10 | ``` 11 | -------------------------------------------------------------------------------- /packages/bulb-input/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env'] 3 | } 4 | -------------------------------------------------------------------------------- /packages/bulb-input/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bulb-input", 3 | "version": "7.0.2", 4 | "description": "Bulb package for working with the keyboard and mouse.", 5 | "homepage": "https://github.com/nullobject/bulb", 6 | "author": "Josh Bassett (https://joshbassett.info)", 7 | "license": "MIT", 8 | "keywords": [ 9 | "bulb" 10 | ], 11 | "main": "dist/bulb-input.js", 12 | "module": "dist/bulb-input.es.js", 13 | "unpkg": "dist/bulb-input.min.js", 14 | "files": [ 15 | "dist" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git://github.com/nullobject/bulb.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/nullobject/bulb/issues" 23 | }, 24 | "standard": { 25 | "env": "jest" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.5.5", 29 | "@babel/preset-env": "^7.5.5", 30 | "babel-jest": "^24.9.0", 31 | "documentation": "^12.1.1", 32 | "jest": "^24.9.0", 33 | "rollup": "^1.20.1", 34 | "rollup-plugin-auto-external": "^2.0.0", 35 | "rollup-plugin-babel": "^4.3.3", 36 | "rollup-plugin-filesize": "^6.2.0", 37 | "rollup-plugin-node-resolve": "^5.2.0", 38 | "rollup-plugin-uglify": "^6.0.2", 39 | "standard": "^14.0.2" 40 | }, 41 | "peerDependencies": { 42 | "bulb": "~7" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/bulb-input/rollup.config.js: -------------------------------------------------------------------------------- 1 | import autoExternal from 'rollup-plugin-auto-external'; 2 | import babel from 'rollup-plugin-babel' 3 | import filesize from 'rollup-plugin-filesize' 4 | import resolve from 'rollup-plugin-node-resolve' 5 | import { uglify } from 'rollup-plugin-uglify' 6 | 7 | import pkg from './package.json' 8 | 9 | const plugins = [ 10 | autoExternal(), 11 | babel({ exclude: '**/node_modules/**' }), 12 | resolve(), 13 | filesize() 14 | ] 15 | 16 | export default [ 17 | { 18 | input: 'src/index.js', 19 | output: [ 20 | { file: pkg.main, format: 'cjs' }, 21 | { file: pkg.module, format: 'es' } 22 | ], 23 | plugins 24 | }, { 25 | input: 'src/index.js', 26 | output: [ 27 | { 28 | file: pkg.unpkg, 29 | format: 'iife', 30 | name: 'Bulb', 31 | extend: 'Bulb', 32 | globals: { bulb: 'Bulb' } 33 | } 34 | ], 35 | plugins: plugins.concat([uglify()]) 36 | } 37 | ] 38 | -------------------------------------------------------------------------------- /packages/bulb-input/src/index.js: -------------------------------------------------------------------------------- 1 | import keyboardKeys from './keyboard/keys' 2 | import keyboardState from './keyboard/state' 3 | import mouseButtons from './mouse/buttons' 4 | import mousePosition from './mouse/position' 5 | import mouseState from './mouse/state' 6 | 7 | /** 8 | * This module provides functions to create signals that wrap the keyboard 9 | * input device. 10 | */ 11 | export const Keyboard = { 12 | /** 13 | * Creates a signal that emits a value if a key is pressed on the `target` DOM 14 | * element. 15 | * 16 | * If a key is held down continuously, then the signal will repeatedly emit 17 | * values at a rate determined by your OS key repeat setting. 18 | * 19 | * @param {EventTarget} target The event target (e.g. a DOM element). 20 | * @param {Object} [options] The options. 21 | * @param {Booelan} [options.preventDefault=false] A boolean indicating whether 22 | * the default action should be taken for the event. 23 | * @returns {Signal} A new signal. 24 | * @example 25 | * 26 | * import { Keyboard } from 'bulb-input' 27 | * 28 | * const s = Keyboard.keys(document) 29 | * 30 | * s.subscribe(console.log) // 1, 2, ... 31 | */ 32 | keys (target, options) { 33 | return keyboardKeys(target, options) 34 | }, 35 | 36 | /** 37 | * Creates a signal that emits a value if the keyboard state changes. 38 | * 39 | * When a key is pressed or released, then the signal will emit an array 40 | * containing the key codes of all the currently pressed keys. 41 | * 42 | * @param {EventTarget} target The event target (e.g. a DOM element). 43 | * @param {Object} [options] The options. 44 | * @param {Booelan} [options.preventDefault=false] A boolean indicating whether 45 | * the default action should be taken for the event. 46 | * @returns {Signal} A new signal. 47 | * @example 48 | * 49 | * import { Keyboard } from 'bulb-input' 50 | * 51 | * const s = Keyboard.state(document) 52 | * 53 | * s.subscribe(console.log) // [1], [1, 2], ... 54 | */ 55 | state (target, options) { 56 | return keyboardState(target, options) 57 | } 58 | } 59 | 60 | /** 61 | * This module provides functions to creating signals that wrap the mouse input 62 | * device. 63 | */ 64 | export const Mouse = { 65 | /** 66 | * Creates a signal that emits a value if a mouse button is pressed. 67 | * 68 | * When a mouse button is pressed, then the signal will emit an integer 69 | * representing the logical sum of the currently pressed button codes (left=1, 70 | * right=2, middle=4). 71 | * 72 | * @param {EventTarget} target The event target (e.g. a DOM element). 73 | * @param {Object} [options] The options. 74 | * @param {Booelan} [options.preventDefault=false] A boolean indicating whether 75 | * the default action should be taken for the event. 76 | * @returns {Signal} A new signal. 77 | * @example 78 | * 79 | * import { mouse } from 'bulb-input' 80 | * 81 | * const s = Mouse.buttons(document) 82 | * 83 | * s.subscribe(console.log) // 1, 2, ... 84 | */ 85 | buttons (target, options) { 86 | return mouseButtons(target, options) 87 | }, 88 | 89 | /** 90 | * Creates a signal that emits a value if the mouse is moved. 91 | * 92 | * When the mouse is moved, then the signal will emit an array containing the 93 | * mouse position. 94 | * 95 | * @param {EventTarget} target The event target (e.g. a DOM element). 96 | * @param {Object} [options] The options. 97 | * @param {Booelan} [options.preventDefault=false] A boolean indicating whether 98 | * the default action should be taken for the event. 99 | * @returns {Signal} A new signal. 100 | * @example 101 | * 102 | * import { mouse } from 'bulb-input' 103 | * 104 | * const s = Mouse.position(document) 105 | * 106 | * s.subscribe(console.log) // [1, 1], [2, 2], ... 107 | */ 108 | position (target, options) { 109 | return mousePosition(target, options) 110 | }, 111 | 112 | /** 113 | * Creates a signal that emits a value if the mouse state changes. 114 | * 115 | * When the mouse is moved or a button is pressed, then the signal will emit 116 | * an object representing the current state of the mouse. 117 | * 118 | * @param {EventTarget} target The event target (e.g. a DOM element). 119 | * @param {Object} [options] The options. 120 | * @param {Booelan} [options.preventDefault=false] A boolean indicating whether 121 | * the default action should be taken for the event. 122 | * @returns {Signal} A new signal. 123 | * @example 124 | * 125 | * import { mouse } from 'bulb-input' 126 | * 127 | * const s = Mouse.state(document) 128 | * 129 | * s.subscribe(console.log) // { buttons: 1, clientX: 1, clientY: 1, ... }, ... 130 | */ 131 | state (target, options) { 132 | return mouseState(target, options) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /packages/bulb-input/src/internal/emitter.js: -------------------------------------------------------------------------------- 1 | import events from 'events' 2 | 3 | /** 4 | * Returns a new event emitter that can be used as a mock object in tests. 5 | * 6 | * @private 7 | */ 8 | export default function emitter () { 9 | const emitter = new events.EventEmitter() 10 | emitter.addEventListener = emitter.on 11 | emitter.removeEventListener = jest.fn() 12 | return emitter 13 | } 14 | -------------------------------------------------------------------------------- /packages/bulb-input/src/keyboard/keys.js: -------------------------------------------------------------------------------- 1 | import { Signal } from 'bulb' 2 | 3 | export default function keys (target, options) { 4 | options = options || { preventDefault: false } 5 | 6 | return new Signal(emit => { 7 | const handler = e => { 8 | if (options.preventDefault) { e.preventDefault() } 9 | emit.next(parseInt(e.keyCode)) 10 | } 11 | 12 | target.addEventListener('keydown', handler, true) 13 | 14 | return () => target.removeEventListener('keydown', handler, true) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /packages/bulb-input/src/keyboard/keys.test.js: -------------------------------------------------------------------------------- 1 | import emitter from '../internal/emitter' 2 | import keys from './keys' 3 | 4 | describe('keys', () => { 5 | it('emits values when a key is pressed down', () => { 6 | const spy = jest.fn() 7 | const e = emitter() 8 | const s = keys(e) 9 | 10 | s.subscribe(spy) 11 | 12 | e.emit('keydown', { keyCode: '1' }) 13 | expect(spy).toHaveBeenCalledWith(1) 14 | }) 15 | 16 | describe('with the preventDefault option set', () => { 17 | it('calls preventDefault on the event', () => { 18 | const spy = jest.fn() 19 | const e = emitter() 20 | const s = keys(e, { preventDefault: true }) 21 | 22 | s.subscribe() 23 | 24 | e.emit('keydown', { preventDefault: spy }) 25 | expect(spy).toHaveBeenCalled() 26 | }) 27 | }) 28 | 29 | it('removes the event listener when it is unsubscribed', () => { 30 | const e = emitter() 31 | const s = keys(e) 32 | const a = s.subscribe() 33 | 34 | a.unsubscribe() 35 | 36 | expect(e.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), true) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /packages/bulb-input/src/keyboard/state.js: -------------------------------------------------------------------------------- 1 | import { Signal } from 'bulb' 2 | 3 | export default function state (target, options) { 4 | options = options || { preventDefault: false } 5 | 6 | return new Signal(emit => { 7 | const state = new Set() 8 | 9 | const downHandler = e => { 10 | if (options.preventDefault) { e.preventDefault() } 11 | 12 | const key = parseInt(e.keyCode) 13 | 14 | if (!state.has(key)) { 15 | state.add(key) 16 | emit.next(Array.from(state)) 17 | } 18 | } 19 | 20 | const upHandler = e => { 21 | if (options.preventDefault) { e.preventDefault() } 22 | 23 | const key = parseInt(e.keyCode) 24 | 25 | if (state.has(key)) { 26 | state.delete(key) 27 | emit.next(Array.from(state)) 28 | } 29 | } 30 | 31 | target.addEventListener('keydown', downHandler, true) 32 | target.addEventListener('keyup', upHandler, true) 33 | 34 | return () => { 35 | target.removeEventListener('keydown', downHandler, true) 36 | target.removeEventListener('keyup', upHandler, true) 37 | } 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /packages/bulb-input/src/keyboard/state.test.js: -------------------------------------------------------------------------------- 1 | import emitter from '../internal/emitter' 2 | import state from './state' 3 | 4 | describe('state', () => { 5 | it('emits values when the keyboard state changes', () => { 6 | const spy = jest.fn() 7 | const e = emitter() 8 | const s = state(e) 9 | 10 | s.subscribe(spy) 11 | 12 | e.emit('keydown', { keyCode: '1' }) 13 | expect(spy).toHaveBeenCalledTimes(1) 14 | expect(spy).toHaveBeenLastCalledWith([1]) 15 | 16 | e.emit('keyup', { keyCode: '1' }) 17 | expect(spy).toHaveBeenCalledTimes(2) 18 | expect(spy).toHaveBeenLastCalledWith([]) 19 | }) 20 | 21 | describe('with the preventDefault option set', () => { 22 | it('calls preventDefault on the event', () => { 23 | const spy = jest.fn() 24 | const e = emitter() 25 | const s = state(e, { preventDefault: true }) 26 | 27 | s.subscribe() 28 | 29 | e.emit('keydown', { preventDefault: spy }) 30 | expect(spy).toHaveBeenCalled() 31 | }) 32 | }) 33 | 34 | it('removes the event listeners when it is unsubscribed', () => { 35 | const e = emitter() 36 | const s = state(e) 37 | const a = s.subscribe() 38 | 39 | a.unsubscribe() 40 | 41 | expect(e.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), true) 42 | expect(e.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function), true) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /packages/bulb-input/src/mouse/buttons.js: -------------------------------------------------------------------------------- 1 | import { Signal } from 'bulb' 2 | 3 | export default function buttons (target, options) { 4 | options = options || { preventDefault: false } 5 | 6 | return new Signal(emit => { 7 | const handler = e => { 8 | if (options.preventDefault) { e.preventDefault() } 9 | emit.next(e.buttons) 10 | } 11 | 12 | target.addEventListener('mousedown', handler, true) 13 | 14 | return () => target.removeEventListener('mousedown', handler, true) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /packages/bulb-input/src/mouse/buttons.test.js: -------------------------------------------------------------------------------- 1 | import buttons from './buttons' 2 | import emitter from '../internal/emitter' 3 | 4 | describe('buttons', () => { 5 | it('returns a new mouse buttons signal', () => { 6 | const spy = jest.fn() 7 | const e = emitter() 8 | const s = buttons(e) 9 | 10 | s.subscribe(spy) 11 | 12 | e.emit('mousedown', { buttons: 1 }) 13 | expect(spy).toHaveBeenCalledWith(1) 14 | }) 15 | 16 | describe('with the preventDefault option set', () => { 17 | it('calls preventDefault on the event', () => { 18 | const spy = jest.fn() 19 | const e = emitter() 20 | const s = buttons(e, { preventDefault: true }) 21 | 22 | s.subscribe() 23 | 24 | e.emit('mousedown', { preventDefault: spy }) 25 | expect(spy).toHaveBeenCalled() 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /packages/bulb-input/src/mouse/position.js: -------------------------------------------------------------------------------- 1 | import { Signal } from 'bulb' 2 | 3 | export default function position (target, options) { 4 | options = options || { preventDefault: false } 5 | 6 | return new Signal(emit => { 7 | const handler = e => { 8 | if (options.preventDefault) { e.preventDefault() } 9 | emit.next([e.clientX, e.clientY]) 10 | } 11 | 12 | target.addEventListener('mousemove', handler, true) 13 | 14 | return () => target.removeEventListener('mousemove', handler, true) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /packages/bulb-input/src/mouse/position.test.js: -------------------------------------------------------------------------------- 1 | import emitter from '../internal/emitter' 2 | import position from './position' 3 | 4 | describe('position', () => { 5 | it('returns a new mouse position signal', () => { 6 | const spy = jest.fn() 7 | const e = emitter() 8 | const s = position(e) 9 | 10 | s.subscribe(spy) 11 | 12 | e.emit('mousemove', { clientX: 1, clientY: 2 }) 13 | expect(spy).toHaveBeenCalledWith([1, 2]) 14 | }) 15 | 16 | describe('with the preventDefault option set', () => { 17 | it('calls preventDefault on the event', () => { 18 | const spy = jest.fn() 19 | const e = emitter() 20 | const s = position(e, { preventDefault: true }) 21 | 22 | s.subscribe() 23 | 24 | e.emit('mousemove', { preventDefault: spy }) 25 | expect(spy).toHaveBeenCalled() 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /packages/bulb-input/src/mouse/state.js: -------------------------------------------------------------------------------- 1 | import { Signal } from 'bulb' 2 | 3 | export default function state (target, options) { 4 | options = options || { preventDefault: false } 5 | 6 | return new Signal(emit => { 7 | const handler = e => { 8 | if (options.preventDefault) { e.preventDefault() } 9 | emit.next(e) 10 | } 11 | 12 | target.addEventListener('mousemove', handler, true) 13 | target.addEventListener('mousedown', handler, true) 14 | target.addEventListener('mouseup', handler, true) 15 | 16 | return () => { 17 | target.removeEventListener('mousemove', handler, true) 18 | target.removeEventListener('mousedown', handler, true) 19 | target.removeEventListener('mouseup', handler, true) 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /packages/bulb-input/src/mouse/state.test.js: -------------------------------------------------------------------------------- 1 | import emitter from '../internal/emitter' 2 | import state from './state' 3 | 4 | describe('state', () => { 5 | it('returns a new mouse position signal', () => { 6 | const spy = jest.fn() 7 | const e = emitter() 8 | const s = state(e) 9 | 10 | s.subscribe(spy) 11 | 12 | e.emit('mousemove', { 13 | buttons: 0, 14 | clientX: 1, 15 | clientY: 2, 16 | ctrlKey: 3, 17 | shiftKey: 4, 18 | altKey: 5, 19 | metaKey: 6 20 | }) 21 | 22 | expect(spy).toHaveBeenCalledWith({ 23 | buttons: 0, 24 | clientX: 1, 25 | clientY: 2, 26 | ctrlKey: 3, 27 | shiftKey: 4, 28 | altKey: 5, 29 | metaKey: 6 30 | }) 31 | }) 32 | 33 | describe('with the preventDefault option set', () => { 34 | it('calls preventDefault on the event', () => { 35 | const spy = jest.fn() 36 | const e = emitter() 37 | const s = state(e, { preventDefault: true }) 38 | 39 | s.subscribe() 40 | 41 | e.emit('mousedown', { preventDefault: spy }) 42 | expect(spy).toHaveBeenCalled() 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /packages/bulb/README.md: -------------------------------------------------------------------------------- 1 | # Bulb 2 | 3 | This package provides the [Bulb](https://github.com/nullobject/bulb) reactive programming 4 | library for JavaScript. 5 | 6 | ## Installation 7 | 8 | ```sh 9 | > npm install bulb 10 | ``` 11 | -------------------------------------------------------------------------------- /packages/bulb/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env'], 3 | plugins: ['@babel/plugin-proposal-class-properties'] 4 | } 5 | -------------------------------------------------------------------------------- /packages/bulb/bulb.d.ts: -------------------------------------------------------------------------------- 1 | export interface Subscription { 2 | unsubscribe(): void 3 | } 4 | 5 | export interface Subscriber { 6 | next(a: A): void 7 | error(e: Error): void 8 | complete(): void 9 | } 10 | 11 | export module Signal { 12 | function of(...as: A[]): Signal 13 | function empty(): Signal 14 | function from(iterable: Iterable | ArrayLike): Signal 15 | function fromCallback(callback: (e: any, a: A) => (() => any)): Signal 16 | function fromPromise(p: Promise): Signal 17 | function periodic(n: number): Signal 18 | function concat(...signals: Signal[]): Signal 19 | function zip(a: Signal, b: Signal): Signal<[A, B]> 20 | function zip(a: Signal, b: Signal, c: Signal): Signal<[A, B, C]> 21 | function zip(a: Signal, b: Signal, c: Signal, d: Signal): Signal<[A, B, C, D]> 22 | function zipWith(f: (a: A, b: B) => R, a: Signal, b: Signal): Signal 23 | function zipWith(f: (a: A, b: B, c: C) => R, a: Signal, b: Signal, c: Signal): Signal 24 | function zipWith(f: (a: A, b: B, c: C, d: D) => R, a: Signal, b: Signal, c: Signal, d: Signal): Signal 25 | function zipLatest(a: Signal, b: Signal): Signal<[A, B]> 26 | function zipLatest(a: Signal, b: Signal, c: Signal): Signal<[A, B, C]> 27 | function zipLatest(a: Signal, b: Signal, c: Signal, d: Signal): Signal<[A, B, C, D]> 28 | function zipLatestWith(f: (a: A, b: B) => R, a: Signal, b: Signal): Signal 29 | function zipLatestWith(f: (a: A, b: B, c: C) => R, a: Signal, b: Signal, c: Signal): Signal 30 | function zipLatestWith(f: (a: A, b: B, c: C, d: D) => R, a: Signal, b: Signal, c: Signal, d: Signal): Signal 31 | } 32 | 33 | export class Signal { 34 | subscribe(f: (a: A) => any): Subscription 35 | subscribe(subscriber: Subscriber): Subscription 36 | 37 | tap(f: (a: A) => any): Signal 38 | 39 | concat(...signals: Signal[]): Signal 40 | merge(...signals: Signal[]): Signal 41 | prepend(...signals: Signal[]): Signal 42 | append(...signals: Signal[]): Signal 43 | startWith(a: A): Signal 44 | endWith(a: A): Signal 45 | always(b: B): Signal 46 | cycle(...values: B[]): Signal 47 | sequential(...values: B[]): Signal 48 | 49 | take(n: number): Signal 50 | takeUntil(s: Signal): Signal 51 | takeWhile(f: (a: A) => boolean): Signal 52 | drop(n: number): Signal 53 | dropUntil(s: Signal): Signal 54 | dropWhile(f: (a: A) => boolean): Signal 55 | first(): Signal 56 | last(): Signal 57 | 58 | any(f: (a: A) => boolean): Signal 59 | all(f: (a: A) => boolean): Signal 60 | 61 | map(f: (a: A) => B): Signal 62 | concatMap(f: (a: A) => Signal): Signal 63 | filter(p: (a: A) => boolean): Signal 64 | fold(f: (b: B, a: A) => B, b: B): Signal 65 | scan(f: (b: B, a: A) => B, b: B): Signal 66 | stateMachine(f: (b: B, a: A, subscriber: Subscriber) => B, init?: B): Signal 67 | zip(b: Signal): Signal<[A, B]> 68 | zip(b: Signal, c: Signal): Signal<[A, B, C]> 69 | zip(b: Signal, c: Signal, d: Signal): Signal<[A, B, C, D]> 70 | zipWith(f: (a: A, b: B) => R, b: Signal): Signal 71 | zipWith(f: (a: A, b: B, c: C) => R, b: Signal, c: Signal): Signal 72 | zipWith(f: (a: A, b: B, c: C, d: D) => R, b: Signal, c: Signal, d: Signal): Signal 73 | zipLatest(b: Signal): Signal<[A, B]> 74 | zipLatest(b: Signal, c: Signal): Signal<[A, B, C]> 75 | zipLatest(b: Signal, c: Signal, d: Signal): Signal<[A, B, C, D]> 76 | zipLatestWith(f: (a: A, b: B) => R, b: Signal): Signal 77 | zipLatestWith(f: (a: A, b: B, c: C) => R, b: Signal, c: Signal): Signal 78 | zipLatestWith(f: (a: A, b: B, c: C, d: D) => R, b: Signal, c: Signal, d: Signal): Signal 79 | 80 | delay(n: number): Signal 81 | debounce(n: number): Signal 82 | throttle(n: number): Signal 83 | dedupe(): Signal 84 | dedupeWith(f: (a: A, b: A) => boolean): Signal 85 | sample(b: Signal): Signal 86 | hold(b: Signal): Signal 87 | encode(b: Signal): Signal 88 | window(b: Signal): Signal> 89 | switchLatest(this: Signal): A 90 | switchMap(f: (a: A) => Signal): Signal 91 | } 92 | -------------------------------------------------------------------------------- /packages/bulb/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | restoreMocks: true 3 | } 4 | -------------------------------------------------------------------------------- /packages/bulb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bulb", 3 | "version": "7.0.1", 4 | "description": "A reactive programming library for JavaScript.", 5 | "homepage": "https://github.com/nullobject/bulb", 6 | "author": "Josh Bassett (https://joshbassett.info)", 7 | "license": "MIT", 8 | "keywords": [ 9 | "functional", 10 | "reactive", 11 | "programming", 12 | "library" 13 | ], 14 | "main": "dist/index.js", 15 | "module": "dist/bulb.esm.js", 16 | "unpkg": "dist/bulb.min.js", 17 | "files": [ 18 | "bulb.d.ts", 19 | "dist" 20 | ], 21 | "types": "bulb.d.ts", 22 | "repository": { 23 | "type": "git", 24 | "url": "git://github.com/nullobject/bulb.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/nullobject/bulb/issues" 28 | }, 29 | "standard": { 30 | "env": "jest" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.12.13", 34 | "@babel/plugin-proposal-class-properties": "^7.12.13", 35 | "@babel/preset-env": "^7.12.13", 36 | "babel-jest": "^26.6.3", 37 | "documentation": "^13.1.1", 38 | "fkit": "^3.4.0", 39 | "jest": "^26.6.3", 40 | "rollup": "^2.38.5", 41 | "rollup-plugin-babel": "^4.4.0", 42 | "rollup-plugin-commonjs": "^10.1.0", 43 | "rollup-plugin-filesize": "^9.1.0", 44 | "rollup-plugin-node-resolve": "^5.2.0", 45 | "rollup-plugin-terser": "^7.0.2", 46 | "standard": "^16.0.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/bulb/rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import filesize from 'rollup-plugin-filesize' 4 | import resolve from 'rollup-plugin-node-resolve' 5 | import { terser } from 'rollup-plugin-terser' 6 | 7 | import pkg from './package.json' 8 | 9 | const plugins = [ 10 | babel(), 11 | commonjs(), 12 | resolve() 13 | ] 14 | 15 | export default [ 16 | { 17 | input: [ 18 | 'src/Bus.js', 19 | 'src/Signal.js', 20 | 'src/index.js' 21 | ], 22 | output: { 23 | dir: 'dist', 24 | format: 'cjs', 25 | esModule: false 26 | }, 27 | plugins 28 | }, { 29 | input: 'src/index.js', 30 | output: [ 31 | { 32 | file: pkg.module, 33 | format: 'esm' 34 | } 35 | ], 36 | plugins: plugins.concat(filesize()) 37 | }, { 38 | input: 'src/index.js', 39 | output: { 40 | file: pkg.unpkg, 41 | format: 'iife', 42 | name: 'Bulb' 43 | }, 44 | plugins: plugins.concat(filesize(), terser()) 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /packages/bulb/src/Bus.js: -------------------------------------------------------------------------------- 1 | import { Signal } from './Signal' 2 | 3 | /** 4 | * The `Bus` class represents a special type of `Signal` that can broadcast 5 | * values to its observers. You can connect other signals to a bus, as well as 6 | * manually emit values and errors. 7 | * 8 | * @example 9 | * 10 | * import { Bus, Signal } from 'bulb' 11 | * 12 | * const bus = new Bus() 13 | * 14 | * // Subscribe to the bus and log emitted values to the console. 15 | * bus.subscribe(console.log) 16 | * 17 | * // Emit a value on the bus. 18 | * bus.next(0) 19 | * 20 | * // Connect a signal to the bus. 21 | * const s = Signal.of(1, 2, 3) 22 | * bus.connect(s) 23 | */ 24 | export class Bus extends Signal { 25 | constructor () { 26 | super(emit => { 27 | this.emit = emit 28 | return () => { this.emit = null } 29 | }) 30 | } 31 | 32 | /** 33 | * Emits the given value to the observers. 34 | * 35 | * @param value The value to emit. 36 | */ 37 | next (value) { 38 | if (this.emit) { 39 | this.emit.next(value) 40 | } 41 | } 42 | 43 | /** 44 | * Emits the given error to the observers. 45 | * 46 | * @param e The error to emit. 47 | */ 48 | error (e) { 49 | if (this.emit) { 50 | this.emit.error(e) 51 | } 52 | } 53 | 54 | /** 55 | * Completes the bus. All observers will be completed, and any further calls 56 | * to `next` or `error` will be ignored. 57 | */ 58 | complete () { 59 | if (this.emit) { 60 | this.emit.complete() 61 | } 62 | } 63 | 64 | /** 65 | * Connects the bus to the given signal. Any values emitted by the signal 66 | * will be forwarded to the bus. 67 | * 68 | * @params {Signal} The signal to connect to the bus. 69 | * @returns {Subscription} A subscription handle. 70 | */ 71 | connect (signal) { 72 | return signal.subscribe(this) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/bulb/src/Bus.test.js: -------------------------------------------------------------------------------- 1 | import { Bus } from './Bus' 2 | import mockSignal from './internal/mockSignal' 3 | 4 | let nextSpy, errorSpy, completeSpy 5 | let bus 6 | 7 | describe('Bus', () => { 8 | beforeEach(() => { 9 | nextSpy = jest.fn() 10 | errorSpy = jest.fn() 11 | completeSpy = jest.fn() 12 | 13 | bus = new Bus() 14 | bus.subscribe(nextSpy, errorSpy, completeSpy) 15 | }) 16 | 17 | describe('#value', () => { 18 | it('emits a given value', () => { 19 | bus.next(1) 20 | expect(nextSpy).toHaveBeenLastCalledWith(1) 21 | }) 22 | }) 23 | 24 | describe('#error', () => { 25 | it('emits a given error', () => { 26 | bus.error(1) 27 | expect(errorSpy).toHaveBeenLastCalledWith(1) 28 | }) 29 | }) 30 | 31 | describe('#complete', () => { 32 | it('completes the bus', () => { 33 | bus.complete() 34 | expect(completeSpy).toHaveBeenCalledTimes(1) 35 | }) 36 | }) 37 | 38 | describe('#connect', () => { 39 | it('forwards values emitted by the given signal to the bus', () => { 40 | const s = mockSignal() 41 | bus.connect(s) 42 | s.next(1) 43 | expect(nextSpy).toHaveBeenLastCalledWith(1) 44 | }) 45 | 46 | it('forwards errors emitted by the given signal to the bus', () => { 47 | const s = mockSignal() 48 | bus.connect(s) 49 | s.error(1) 50 | expect(errorSpy).toHaveBeenLastCalledWith(1) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /packages/bulb/src/Signal.js: -------------------------------------------------------------------------------- 1 | import eq from 'fkit/dist/_eq' 2 | import id from 'fkit/dist/id' 3 | import tuple from 'fkit/dist/tuple' 4 | 5 | import Subscription from './Subscription' 6 | import all from './combinators/all' 7 | import always from './combinators/always' 8 | import any from './combinators/any' 9 | import apply from './combinators/apply' 10 | import buffer from './combinators/buffer' 11 | import bufferWith from './combinators/bufferWith' 12 | import catchError from './combinators/catchError' 13 | import concat from './combinators/concat' 14 | import concatMap from './combinators/concatMap' 15 | import cycle from './combinators/cycle' 16 | import debounce from './combinators/debounce' 17 | import dedupeWith from './combinators/dedupeWith' 18 | import delay from './combinators/delay' 19 | import drop from './combinators/drop' 20 | import dropUntil from './combinators/dropUntil' 21 | import dropWhile from './combinators/dropWhile' 22 | import filter from './combinators/filter' 23 | import fold from './combinators/fold' 24 | import hold from './combinators/hold' 25 | import map from './combinators/map' 26 | import merge from './combinators/merge' 27 | import sample from './combinators/sample' 28 | import scan from './combinators/scan' 29 | import sequential from './combinators/sequential' 30 | import stateMachine from './combinators/stateMachine' 31 | import switchMap from './combinators/switchMap' 32 | import take from './combinators/take' 33 | import takeUntil from './combinators/takeUntil' 34 | import takeWhile from './combinators/takeWhile' 35 | import tap from './combinators/tap' 36 | import throttle from './combinators/throttle' 37 | import window from './combinators/window' 38 | import zipLatestWith from './combinators/zipLatestWith' 39 | import zipWith from './combinators/zipWith' 40 | import { asap } from './scheduler' 41 | 42 | /** 43 | * Creates a callback function that emits values to a list of subscribers. 44 | * 45 | * @private 46 | * @param {Array} subscriptions An array of subscriptions. 47 | * @param {String} type The type of callback to create. 48 | * @returns {Function} A function that emits a value to all of the subscriptions. 49 | */ 50 | function broadcast (subscriptions, type) { 51 | return value => { 52 | subscriptions.forEach(s => { 53 | if (typeof s.emit[type] === 'function') { 54 | s.emit[type](value) 55 | } 56 | }) 57 | } 58 | } 59 | 60 | /** 61 | * The `Signal` class represents a time-varying source of values – for example, 62 | * the value of a text input, a periodic timer, or even the position of the 63 | * mouse pointer in the browser window. 64 | * 65 | * When creating a new signal you must provide a `mount` function, which when 66 | * called will connect the signal to a source of values. This function will 67 | * only be called once an observer has subscribed to the signal. This is 68 | * because signals are lazy – they don't bother emitting values until an 69 | * observer is listening. 70 | * 71 | * The `mount` function takes an `emit` object as its only argument. This 72 | * allows the signal to emit values: 73 | * 74 | * * `emit.next(a)` - Emits the value `a`. 75 | * * `emit.error(e)` - Emits the error `e`. 76 | * * `emit.complete()` - Marks the signal as complete. 77 | * 78 | * The `mount` function can also optionally return an unmount function, which 79 | * when called will disconnect the signal from its source of values. This 80 | * function will only be called once all observers have unsubscribed from the 81 | * signal. 82 | * 83 | * @param {Function} mount The function that is used to connect the signal with 84 | * a source of values. It can optionally return an unmount function. 85 | * 86 | * @example 87 | * 88 | * import { Signal } from 'bulb' 89 | * 90 | * // Create a signal that emits the value 'foo' every second. 91 | * const s = new Signal(emit => { 92 | * // Start the timer and emit a value whenever the timer fires. 93 | * const id = setInterval(() => emit.next('foo'), 1000) 94 | * 95 | * // Return a function to be called when the signal is unmounted. 96 | * return () => clearInterval(id) 97 | * }) 98 | * 99 | * // Subscribe to the signal and log emitted values to the console. 100 | * const subscription = s.subscribe(console.log) 101 | * 102 | * // When we are done, we can unsubscribe from the signal. 103 | * subscription.unsubscribe() 104 | */ 105 | export class Signal { 106 | constructor (mount) { 107 | if (typeof mount !== 'function') { 108 | throw new TypeError('Signal mount must be a function') 109 | } 110 | 111 | this._mount = mount 112 | this._unmount = null 113 | this._subscriptions = new Set() 114 | } 115 | 116 | /** 117 | * Mounts the signal. 118 | * 119 | * @private 120 | */ 121 | tryMount (emit) { 122 | this._unmount = this._mount(emit) 123 | } 124 | 125 | /** 126 | * Unmounts the signal. 127 | * 128 | * @private 129 | */ 130 | tryUnmount () { 131 | if (typeof this._unmount === 'function') { 132 | this._unmount() 133 | } 134 | 135 | this._unmount = null 136 | } 137 | 138 | /** 139 | * Concatenates the given signals and emits their values. The returned signal 140 | * will join the signals, waiting for each one to complete before joining the 141 | * next, and will complete once *all* of the signals have completed. 142 | * 143 | * @param {...Signal} signals The signals to concatenate. 144 | * @returns {Signal} A new signal. 145 | * @example 146 | * 147 | * import { Signal } from 'bulb' 148 | * 149 | * const s = Signal.of(1, 2, 3) 150 | * const t = Signal.of(4, 5, 6) 151 | * const u = Signal.concat(s, t) 152 | * 153 | * u.subscribe(console.log) // 1, 2, 3, 4, 5, 6 154 | */ 155 | static concat (...signals) { 156 | return new Signal(concat(signals)) 157 | } 158 | 159 | /** 160 | * Creates a signal that never emits any values and has already completed. 161 | * 162 | * This method is not very useful on its own, but it can be used with other 163 | * combinators (e.g. `fold`, `scan`, etc). 164 | * 165 | * @returns {Signal} A new signal. 166 | */ 167 | static empty () { 168 | return new Signal(emit => { 169 | asap(() => emit.complete()) 170 | }) 171 | } 172 | 173 | /** 174 | * Creates a signal that immediately emits the values from an `iterable`. 175 | * The returned signal will complete after the last value in the iterable has 176 | * been emitted. 177 | * 178 | * @param iterable The iterable that contains the values to be emitted. 179 | * @returns {Signal} A new signal. 180 | * @example 181 | * 182 | * import { Signal } from 'bulb' 183 | * 184 | * const s = Signal.from([1, 2, 3]) 185 | * 186 | * s.subscribe(console.log) // 1, 2, 3 187 | */ 188 | static from (iterable) { 189 | return new Signal(emit => { 190 | asap(() => { 191 | for (const a of iterable) { 192 | emit.next(a) 193 | } 194 | emit.complete() 195 | }) 196 | }) 197 | } 198 | 199 | /** 200 | * Creates a signal that wraps a callback. 201 | * 202 | * The executor function `f` is passed with a `callback` function when the 203 | * signal is mounted. The `callback` is a standard *error-first callback*, 204 | * which means that if the callback is called with a non-`null` first 205 | * argument, then the returned signal will emit an error. If the callback is 206 | * called with a `null` first argument, then the returned signal will emit a 207 | * value. 208 | * 209 | * @param {Function} f The executor function to be passed with a callback 210 | * function when the signal is mounted. 211 | * @returns {Signal} A new signal. 212 | * @example 213 | * 214 | * import { Signal } from 'bulb' 215 | * 216 | * const s = Signal.fromCallback(callback => { 217 | * callback(null, 'foo') 218 | * }) 219 | * 220 | * s.subscribe(console.log) // 'foo' 221 | */ 222 | static fromCallback (f) { 223 | return new Signal(emit => 224 | f((e, a) => { 225 | if (e !== 'undefined' && e !== null) { 226 | emit.error(e) 227 | } else { 228 | emit.next(a) 229 | } 230 | }) 231 | ) 232 | } 233 | 234 | /** 235 | * Creates a signal that emits events of `type` from the 236 | * `EventTarget`-compatible `target` object. 237 | * 238 | * @param {String} type The event type to listen for. 239 | * @param {EventTarget} target The event target (e.g. a DOM element). 240 | * @param {Object} [options] The options. 241 | * @param {Boolean} [options.useCapture=true] A boolean indicating that 242 | * events of this type will be dispatched to the signal before being 243 | * dispatched to any `EventTarget` beneath it in the DOM tree. 244 | * @returns {Signal} A new signal. 245 | * @example 246 | * 247 | * import { Signal } from 'bulb' 248 | * 249 | * Signal.fromEvent('click', document) 250 | */ 251 | static fromEvent (type, target, options) { 252 | options = options || { useCapture: true } 253 | 254 | return new Signal(emit => { 255 | if (target.addListener) { 256 | target.addListener(type, emit.next) 257 | } else if (target.addEventListener) { 258 | target.addEventListener(type, emit.next, options.useCapture) 259 | } 260 | 261 | return () => { 262 | if (target.addListener) { 263 | target.removeListener(type, emit.next) 264 | } else { 265 | target.removeEventListener('type', emit.next, options.useCapture) 266 | } 267 | } 268 | }) 269 | } 270 | 271 | /** 272 | * Creates a signal that wraps a promise `p`. The returned signal will 273 | * complete immediately after the promise is resolved. 274 | * 275 | * @param {Promise} p The promise to wrap. 276 | * @returns {Signal} A new signal. 277 | * @example 278 | * 279 | * import { Signal } from 'bulb' 280 | * 281 | * const p = new Promise((resolve, reject) => { 282 | * resolve('foo') 283 | * }) 284 | * const s = Signal.fromPromise(p) 285 | * 286 | * s.subscribe(console.log) // 'foo' 287 | */ 288 | static fromPromise (p) { 289 | return new Signal(emit => { 290 | p.then(emit.next, emit.error).finally(emit.complete) 291 | }) 292 | } 293 | 294 | /** 295 | * Merges the given signals and emits their values. The returned signal will 296 | * complete once *all* of the signals have completed. 297 | * 298 | * @param {...Signal} signals The signals to merge. 299 | * @returns {Signal} A new signal. 300 | * @example 301 | * 302 | * import { Signal } from 'bulb' 303 | * 304 | * const s = Signal.of(1, 2, 3) 305 | * const t = Signal.of(4, 5, 6) 306 | * const u = Signal.merge(s, t) 307 | * 308 | * u.subscribe(console.log) // 1, 4, 2, 5, 3, 6 309 | */ 310 | static merge (...signals) { 311 | return new Signal(merge(signals)) 312 | } 313 | 314 | /** 315 | * Creates a signal that never emits any values or completes. 316 | * 317 | * This method is not very useful on its own, but it can be used with other 318 | * combinators (e.g. `fold`, `scan`, etc). 319 | * 320 | * @returns {Signal} A new signal. 321 | */ 322 | static never () { 323 | return new Signal(() => {}) 324 | } 325 | 326 | /** 327 | * Creates a signal that immediately emits the `values`. The returned signal 328 | * will complete immediately after the values have been emited. 329 | * 330 | * @param values The values to emit. 331 | * @returns {Signal} A new signal. 332 | * @example 333 | * 334 | * import { Signal } from 'bulb' 335 | * 336 | * const s = Signal.of(1, 2, 3) 337 | * 338 | * s.subscribe(console.log) // 1, 2, 3 339 | */ 340 | static of (...values) { 341 | return new Signal(emit => { 342 | asap(() => { 343 | values.map(a => emit.next(a)) 344 | emit.complete() 345 | }) 346 | }) 347 | } 348 | 349 | /** 350 | * Creates a signal that emits a value every `n` milliseconds. The value 351 | * emitted starts at zero and increments indefinitely. 352 | * 353 | * @param {Number} n The number of milliseconds to wait between each value. 354 | * @returns {Signal} A new signal. 355 | * @example 356 | * 357 | * import { Signal } from 'bulb' 358 | * 359 | * const s = Signal.periodic(1000) 360 | * 361 | * s.subscribe(console.log) // 0, 1, 2, ... 362 | */ 363 | static periodic (n) { 364 | return new Signal(emit => { 365 | let count = 0 366 | const id = setInterval(() => emit.next(count++), n) 367 | return () => clearInterval(id) 368 | }) 369 | } 370 | 371 | /** 372 | * Creates a signal that emits an error. The returned signal will complete 373 | * immediately after the error has been emited. 374 | * 375 | * @param e The error to emit. 376 | * @returns {Signal} A new signal. 377 | * @example 378 | * 379 | * import { Signal } from 'bulb' 380 | * 381 | * const s = Signal.error('foo') 382 | * 383 | * s.subscribe({ error: console.error }) // 'foo' 384 | */ 385 | static throwError (e) { 386 | return new Signal(emit => { 387 | asap(() => { 388 | emit.error(e) 389 | emit.complete() 390 | }) 391 | }) 392 | } 393 | 394 | /** 395 | * Combines the corresponding values emitted by the given signals into 396 | * tuples. The returned signal will complete when *any* of the signals have 397 | * completed. 398 | * 399 | * @param {...Signal} signals The signals to zip. 400 | * @returns {Signal} A new signal. 401 | * @example 402 | * 403 | * import { Signal } from 'bulb' 404 | * 405 | * const s = Signal.of(1, 2, 3) 406 | * const t = Signal.of(4, 5, 6) 407 | * const u = Signal.zip(s, t) 408 | * 409 | * u.subscribe(console.log) // [1, 4], [2, 5], [3, 6] 410 | */ 411 | static zip (...signals) { 412 | return new Signal(zipWith(tuple, signals)) 413 | } 414 | 415 | /** 416 | * Applies the function `f` to the corresponding values emitted by the given 417 | * signals. The returned signal will complete when *any* of the signals have 418 | * completed. 419 | * 420 | * @param {Function} f The function to apply to the corresponding values 421 | * emitted by the signals. 422 | * @param {...Signal} signals The signals to zip. 423 | * @returns {Signal} A new signal. 424 | * @example 425 | * 426 | * import { Signal } from 'bulb' 427 | * 428 | * const s = Signal.of(1, 2, 3) 429 | * const t = Signal.of(4, 5, 6) 430 | * const u = Signal.zipWith((a, b) => a + b, s, t) 431 | * 432 | * u.subscribe(console.log) // 5, 7, 9 433 | */ 434 | static zipWith (f, ...signals) { 435 | return new Signal(zipWith(f, signals)) 436 | } 437 | 438 | /** 439 | * Combines the latest values emitted by the given signals into tuples. The 440 | * returned signal will complete when *any* of the signals have completed. 441 | * 442 | * @param {...Signal} signals The signals to zip. 443 | * @returns {Signal} A new signal. 444 | * @example 445 | * 446 | * import { Signal } from 'bulb' 447 | * 448 | * const s = Signal.of(1, 2, 3) 449 | * const t = Signal.of(4, 5, 6) 450 | * const u = Signal.zipLatest(s, t) 451 | * 452 | * u.subscribe(console.log) // [1, 4], [2, 5], [3, 6] 453 | */ 454 | static zipLatest (...signals) { 455 | return new Signal(zipLatestWith(tuple, signals)) 456 | } 457 | 458 | /** 459 | * Applies the function `f` to the latest values emitted by the given signals. 460 | * The returned signal will complete when *any* of the signals have completed. 461 | * 462 | * @param {Function} f The function to apply to the corresponding values 463 | * emitted by the signals. 464 | * @param {...Signal} signals The signals to zip. 465 | * @returns {Signal} A new signal. 466 | * @example 467 | * 468 | * import { Signal } from 'bulb' 469 | * 470 | * const s = Signal.of(1, 2, 3) 471 | * const t = Signal.of(4, 5, 6) 472 | * const u = Signal.zipLatestWith((a, b) => a + b, s, t) 473 | * 474 | * u.subscribe(console.log) // 5, 7, 9 475 | */ 476 | static zipLatestWith (f, ...signals) { 477 | return new Signal(zipLatestWith(f, signals)) 478 | } 479 | 480 | /** 481 | * Emits `true` if *all* the values emitted by the signal satisfy a predicate 482 | * function `p`. The returned signal will complete if the signal emits *any* 483 | * value that doesn't satisfy the predictate function. 484 | * 485 | * @param {Function} p The predicate function to apply to each value emitted 486 | * by the signal. 487 | * @returns {Signal} A new signal. 488 | * @example 489 | * 490 | * import { Signal } from 'bulb' 491 | * 492 | * const s = Signal 493 | * .of(1, 2, 3) 494 | * .all(a => a > 0) 495 | * 496 | * s.subscribe(console.log) // true 497 | */ 498 | all (p) { 499 | return new Signal(all(p, this)) 500 | } 501 | 502 | /** 503 | * Replaces the values of the signal with a constant value 504 | * 505 | * @param value The constant value. 506 | * @returns {Signal} A new signal. 507 | * @example 508 | * 509 | * import { Signal } from 'bulb' 510 | * 511 | * const s = Signal 512 | * .periodic(1000) 513 | * .always(1) 514 | * 515 | * s.subscribe(console.log) // 1, 1, 1, ... 516 | */ 517 | always (value) { 518 | return new Signal(always(value, this)) 519 | } 520 | 521 | /** 522 | * Emits `true` if *any* of the values emitted by the signal satisfy a 523 | * predicate function `p`. The returned signal will complete if the signal 524 | * emits *any* value that satisfies the predictate function. 525 | * 526 | * @param {Function} p The predicate function to apply to each value emitted 527 | * by the signal. 528 | * @returns {Signal} A new signal. 529 | * @example 530 | * 531 | * import { Signal } from 'bulb' 532 | * 533 | * const s = Signal 534 | * .of(1, 2, 3) 535 | * .any(a => a < 0) 536 | * 537 | * s.subscribe(console.log) // false 538 | */ 539 | any (p) { 540 | return new Signal(any(p, this)) 541 | } 542 | 543 | /** 544 | * Emits the given values after the signal has completed. 545 | * 546 | * @param values The values to append. 547 | * @returns {Signal} A new signal. 548 | * @example 549 | * 550 | * import { Signal } from 'bulb' 551 | * 552 | * const s = Signal 553 | * .of(1, 2, 3) 554 | * .append(4, 5, 6) 555 | * 556 | * s.subscribe(console.log) // 1, 2, 3, 4, 5, 6 557 | */ 558 | append (...values) { 559 | return new Signal(concat([this, Signal.from(values)])) 560 | } 561 | 562 | /** 563 | * Applies the latest function emitted by the signal to latest values emitted 564 | * by the given signals. The returned signal will complete when *any* of the 565 | * signals have completed. 566 | * 567 | * The latest function will be called with a number of arguments equal to the 568 | * number of signals. For example, if the latest function is `(a, b) => a + b`, 569 | * then you will need to supply two signals. 570 | * 571 | * @param {...Signal} signals The value signals. 572 | * @returns {Signal} A new signal. 573 | * @example 574 | * 575 | * import { Signal } from 'bulb' 576 | * 577 | * const s = Signal.of(1, 2, 3) 578 | * const t = Signal.of(4, 5, 6) 579 | * const u = Signal 580 | * .of((a, b) => a + b) 581 | * .apply(s, t) 582 | * 583 | * u.subscribe(console.log) // 5, 7, 9 584 | */ 585 | apply (...signals) { 586 | return new Signal(apply(this, signals)) 587 | } 588 | 589 | /** 590 | * Buffers values emitted by the signal and emits the buffer contents when it 591 | * is full. The buffer contents will be emitted when the signal completes, 592 | * regardless of whether the buffer is full. 593 | * 594 | * @param {Number} [n=Infinity] The size of the buffer. If the size is set to 595 | * `Infinity`, then the signal will be buffered until it completes. 596 | * @returns {Signal} A new signal. 597 | * @example 598 | * 599 | * import { Signal } from 'bulb' 600 | * 601 | * const s = Signal 602 | * .of(1, 2, 3, 4) 603 | * .buffer(2) 604 | * 605 | * s.subscribe(console.log) // [1, 2], [3, 4], ... 606 | */ 607 | buffer (n = Infinity) { 608 | return new Signal(buffer(n, this)) 609 | } 610 | 611 | /** 612 | * Buffers values emitted by the signal and emits the buffer contents whenever 613 | * there is an event on the given control signal. The buffer contents will be 614 | * emitted when the signal completes, regardless of whether the buffer is 615 | * full. 616 | * 617 | * @param {Signal} signal The control signal. 618 | * @returns {Signal} A new signal. 619 | * @example 620 | * 621 | * import { Signal } from 'bulb' 622 | * 623 | * const t = Signal.periodic(1000) 624 | * const s = Signal 625 | * .of(1, 2, 3, 4) 626 | * .bufferWith(t) 627 | * 628 | * s.subscribe(console.log) // [1, 2], [3, 4], ... 629 | */ 630 | bufferWith (signal) { 631 | return new Signal(bufferWith(signal, this)) 632 | } 633 | 634 | /** 635 | * Applies a function `f`, that returns a `Signal`, to the first error 636 | * emitted by the signal. The returned signal will emit values from the 637 | * signal returned by the function. 638 | * 639 | * @param {Function} f The function to apply to an error emitted by the 640 | * signal. It must also return a `Signal`. 641 | * @returns {Signal} A new signal. 642 | * @example 643 | * 644 | * import { Signal } from 'bulb' 645 | * 646 | * const s = Signal 647 | * .throwError() 648 | * .catchError(e => Signal.of(1)) 649 | * 650 | * s.subscribe(console.log) // 1 651 | */ 652 | catchError (f) { 653 | return new Signal(catchError(f, this)) 654 | } 655 | 656 | /** 657 | * Concatenates the given signals and emits their values. The returned signal 658 | * will join the signals, waiting for each one to complete before joining the 659 | * next, and will complete once *all* of the signals have completed. 660 | * 661 | * @param {...Signal} signals The signals to concatenate. 662 | * @returns {Signal} A new signal. 663 | * @example 664 | * 665 | * import { Signal } from 'bulb' 666 | * 667 | * const s = Signal.of(1, 2, 3) 668 | * const t = Signal.of(4, 5, 6) 669 | * const u = s.concat(t) 670 | * 671 | * u.subscribe(console.log) // 1, 2, 3, 4, 5, 6 672 | */ 673 | concat (...signals) { 674 | return new Signal(concat([this].concat(signals))) 675 | } 676 | 677 | /** 678 | * Applies a function `f`, that returns a `Signal`, to each value emitted by 679 | * the signal. The returned signal will join all signals returned by the 680 | * function, waiting for each one to complete before merging the next. 681 | * 682 | * @param {Function} f The function to apply to each value emitted by the 683 | * signal. It must also return a `Signal`. 684 | * @returns {Signal} A new signal. 685 | * @example 686 | * 687 | * import { Signal } from 'bulb' 688 | * 689 | * const s = Signal 690 | * .of(1, 2, 3) 691 | * .concatMap(a => Signal.of(a + 1)) 692 | * 693 | * s.subscribe(console.log) // 2, 3, 4 694 | */ 695 | concatMap (f) { 696 | return new Signal(concatMap(f, this)) 697 | } 698 | 699 | /** 700 | * Cycles through the given values as values are emitted by the signal. 701 | * 702 | * @param values The values to emit. 703 | * @returns {Signal} A new signal. 704 | * @example 705 | * 706 | * import { Signal } from 'bulb' 707 | * 708 | * const s = Signal 709 | * .periodic(1000) 710 | * .cycle(1, 2, 3) 711 | * 712 | * s.subscribe(console.log) // 1, 2, 3, 1, 2, 3, ... 713 | */ 714 | cycle (...values) { 715 | return new Signal(cycle(values, this)) 716 | } 717 | 718 | /** 719 | * Waits until `n` milliseconds after the last burst of values before 720 | * emitting the most recent value from the signal. 721 | * 722 | * @param {Number} n The number of milliseconds to wait between each burst of 723 | * values. 724 | * @returns {Signal} A new signal. 725 | * @example 726 | * 727 | * import { Mouse } from 'bulb-input' 728 | * 729 | * const s = Mouse 730 | * .position(document) 731 | * .debounce(1000) 732 | * 733 | * s.subscribe(console.log) // [1, 1], [2, 2], ... 734 | */ 735 | debounce (n) { 736 | return new Signal(debounce(n, this)) 737 | } 738 | 739 | /** 740 | * Removes duplicate values emitted by the signal. 741 | * 742 | * @returns {Signal} A new signal. 743 | * @example 744 | * 745 | * import { Signal } from 'bulb' 746 | * 747 | * const s = Signal 748 | * .of(1, 2, 2, 3, 3, 3) 749 | * .dedupe() 750 | * 751 | * s.subscribe(console.log) // 1, 2, 3 752 | */ 753 | dedupe () { 754 | return new Signal(dedupeWith(eq, this)) 755 | } 756 | 757 | /** 758 | * Removes duplicate values emitted by the signal using a comparator function 759 | * `f`. 760 | * 761 | * @param {Function} f The comparator function to apply to successive values 762 | * emitted by the signal. If the value is distinct from the previous value, 763 | * then the comparator function should return `true`, otherwise it should 764 | * return `false`. 765 | * @returns {Signal} A new signal. 766 | * @example 767 | * 768 | * import { Signal } from 'bulb' 769 | * 770 | * const s = Signal 771 | * .of(1, 2, 2, 3, 3, 3) 772 | * .dedupeWith((a, b) => a === b) 773 | * 774 | * s.subscribe(console.log) // 1, 2, 3 775 | */ 776 | dedupeWith (f) { 777 | return new Signal(dedupeWith(f, this)) 778 | } 779 | 780 | /** 781 | * Delays each value emitted by the signal for `n` milliseconds. 782 | * 783 | * @param {Number} n The number of milliseconds to delay. 784 | * @returns {Signal} A new signal. 785 | * @example 786 | * 787 | * import { Mouse } from 'bulb-input' 788 | * 789 | * const s = Mouse 790 | * .position(document) 791 | * .delay(1000) 792 | * 793 | * s.subscribe(console.log) // [1, 1], [2, 2], ... 794 | */ 795 | delay (n) { 796 | return new Signal(delay(n, this)) 797 | } 798 | 799 | /** 800 | * Drops the first `n` values emitted by the signal. 801 | * 802 | * @param {Number} n The number of values to drop. 803 | * @returns {Signal} A new signal. 804 | * @example 805 | * 806 | * import { Signal } from 'bulb' 807 | * 808 | * const s = Signal 809 | * .of(1, 2, 3) 810 | * .drop(2) 811 | * 812 | * s.subscribe(console.log) // 3 813 | */ 814 | drop (n) { 815 | return new Signal(drop(n, this)) 816 | } 817 | 818 | /** 819 | * Drops values emitted by the signal until the given control signal emits a 820 | * value. 821 | * 822 | * @param {Signal} signal The control signal. 823 | * @returns {Signal} A new signal. 824 | * @example 825 | * 826 | * const s = Signal.periodic(1000) 827 | * const t = Signal.of().delay(1000) 828 | * const u = s.dropUntil(t) 829 | * 830 | * u.subscribe(console.log) // 1, 2 831 | */ 832 | dropUntil (signal) { 833 | return new Signal(dropUntil(signal, this)) 834 | } 835 | 836 | /** 837 | * Drops values emitted by the signal while the predicate function `p` is 838 | * satisfied. The returned signal will emit values once the predicate 839 | * function is not satisfied. 840 | * 841 | * @param {Function} p The predicate function to apply to each value emitted 842 | * by the signal. If it returns `true`, the value will not be emitted, 843 | * otherwise the value will be emitted. 844 | * @returns {Signal} A new signal. 845 | * @example 846 | * 847 | * import { Signal } from 'bulb' 848 | * 849 | * const s = Signal 850 | * .of(1, 2, 3) 851 | * .dropWhile(a => a < 2) 852 | * 853 | * s.subscribe(console.log) // 2, 3 854 | */ 855 | dropWhile (p) { 856 | return new Signal(dropWhile(p, this)) 857 | } 858 | 859 | /** 860 | * Switches between the given signals based on the most recent value emitted 861 | * by the signal. The values emitted by the signal represent the index of the 862 | * signal to switch to. 863 | * 864 | * @param {...Signal} signals The signals to encode. 865 | * @returns {Signal} A new signal. 866 | * @example 867 | * 868 | * import { Signal } from 'bulb' 869 | * 870 | * const s = Signal.of(1) 871 | * const t = Signal.of(2) 872 | * const u = Signal 873 | * .periodic(1000) 874 | * .sequential(0, 1) 875 | * .encode(s, t) 876 | * 877 | * u.subscribe(console.log) // 1, 2 878 | */ 879 | encode (...signals) { 880 | const s = new Signal(map(a => signals[a], this)) 881 | return new Signal(switchMap(id, s)) 882 | } 883 | 884 | /** 885 | * Emits a value when the signal has completed. 886 | * 887 | * @param value The value to emit. 888 | * @returns {Signal} A new signal. 889 | * @example 890 | * 891 | * import { Signal } from 'bulb' 892 | * 893 | * const s = Signal 894 | * .of(1, 2, 3) 895 | * .endWith(4) 896 | * 897 | * s.subscribe(console.log) // 1, 2, 3, 4 898 | */ 899 | endWith (value) { 900 | return new Signal(concat([this, Signal.of(value)])) 901 | } 902 | 903 | /** 904 | * Filters the signal by only emitting values that satisfy a predicate 905 | * function `p`. 906 | * 907 | * @param {Function} p The predicate function to apply to each value emitted 908 | * by the signal. If it returns `true`, the value will be emitted, otherwise 909 | * the value will not be emitted. 910 | * @returns {Signal} A new signal. 911 | * @example 912 | * 913 | * import { Signal } from 'bulb' 914 | * 915 | * const s = Signal 916 | * .of(1, 2, 3) 917 | * .filter(a => a > 1) 918 | * 919 | * s.subscribe(console.log) // 2, 3 920 | */ 921 | filter (p) { 922 | return new Signal(filter(p, this)) 923 | } 924 | 925 | /** 926 | * Emits the first value from the signal, and then completes. 927 | * 928 | * @returns {Signal} A new signal. 929 | * @example 930 | * 931 | * import { Signal } from 'bulb' 932 | * 933 | * const s = Signal 934 | * .of(1, 2, 3) 935 | * .first() 936 | * 937 | * s.subscribe(console.log) // 1 938 | */ 939 | first () { 940 | return new Signal(take(1, this)) 941 | } 942 | 943 | /** 944 | * Applies an accumulator function `f` to each value emitted by the signal. 945 | * The accumulated value will be emitted when the signal has completed. 946 | * 947 | * @param {Function} f The accumulator function to apply to each value 948 | * emitted by the signal. 949 | * @param initialValue The initial value. 950 | * @returns {Signal} A new signal. 951 | * @example 952 | * 953 | * import { Signal } from 'bulb' 954 | * 955 | * const s = Signal 956 | * .of(1, 2, 3) 957 | * .fold((a, b) => a + b, 0) 958 | * 959 | * s.subscribe(console.log) // 6 960 | */ 961 | fold (f, initialValue) { 962 | return new Signal(fold(f, initialValue, this)) 963 | } 964 | 965 | /** 966 | * Stops emitting values from the signal while the given control signal is 967 | * truthy. 968 | * 969 | * @param {Signal} signal The control signal. 970 | * @returns {Signal} A new signal. 971 | * @example 972 | * 973 | * import { Mouse } from 'bulb-input' 974 | * 975 | * const s = Mouse.position(document) 976 | * const t = Mouse.button(document) 977 | * const u = s.hold(t) 978 | * 979 | * u.subscribe(console.log) // [1, 1], [2, 2], ... 980 | */ 981 | hold (signal) { 982 | return new Signal(hold(signal, this)) 983 | } 984 | 985 | /** 986 | * Emits the last value from the signal, and then completes. 987 | * 988 | * @returns {Signal} A new signal. 989 | * @example 990 | * 991 | * import { Signal } from 'bulb' 992 | * 993 | * const s = Signal 994 | * .of(1, 2, 3) 995 | * .last() 996 | * 997 | * s.subscribe(console.log) // 3 998 | */ 999 | last () { 1000 | return new Signal(fold((a, b) => b, null, this)) 1001 | } 1002 | 1003 | /** 1004 | * Applies a function `f` to each value emitted by the signal. 1005 | * 1006 | * @param {Function} f The function to apply to each value emitted by the 1007 | * signal. 1008 | * @returns {Signal} A new signal. 1009 | * @example 1010 | * 1011 | * import { Signal } from 'bulb' 1012 | * 1013 | * const s = Signal 1014 | * .of(1, 2, 3) 1015 | * .map(a => a + 1) 1016 | * 1017 | * s.subscribe(console.log) // 2, 3, 4 1018 | */ 1019 | map (f) { 1020 | return new Signal(map(f, this)) 1021 | } 1022 | 1023 | /** 1024 | * Merges the given signals and emits their values. The returned signal will 1025 | * complete once *all* of the signals have completed. 1026 | * 1027 | * @param {...Signal} signals The signals to merge. 1028 | * @returns {Signal} A new signal. 1029 | * @example 1030 | * 1031 | * import { Signal } from 'bulb' 1032 | * 1033 | * const s = Signal.of(1, 2, 3) 1034 | * const t = Signal.of(4, 5, 6) 1035 | * const u = s.merge(t) 1036 | * 1037 | * u.subscribe(console.log) // 1, 4, 2, 5, 3, 6 1038 | */ 1039 | merge (...signals) { 1040 | return new Signal(merge([this].concat(signals))) 1041 | } 1042 | 1043 | /** 1044 | * Emits the given values before any other values are emitted by the signal. 1045 | * 1046 | * @param values The values to prepend. 1047 | * @returns {Signal} A new signal. 1048 | * @example 1049 | * 1050 | * import { Signal } from 'bulb' 1051 | * 1052 | * const s = Signal 1053 | * .of(1, 2, 3) 1054 | * .prepend(4, 5, 6) 1055 | * 1056 | * s.subscribe(console.log) // 4, 5, 6, 1, 2, 3 1057 | */ 1058 | prepend (...values) { 1059 | return new Signal(concat([Signal.from(values), this])) 1060 | } 1061 | 1062 | /** 1063 | * Emits the most recent value from the signal whenever there is an event on 1064 | * the given control signal. 1065 | * 1066 | * @param {Signal} signal The control signal. 1067 | * @returns {Signal} A new signal. 1068 | * @example 1069 | * 1070 | * import { Mouse } from 'bulb-input' 1071 | * import { Signal } from 'bulb' 1072 | * 1073 | * const s = Mouse.position(document) 1074 | * const t = Signal.periodic(1000) 1075 | * const u = s.sample(t) 1076 | * 1077 | * u.subscribe(console.log) // [1, 1], [2, 2], ... 1078 | */ 1079 | sample (signal) { 1080 | return new Signal(sample(signal, this)) 1081 | } 1082 | 1083 | /** 1084 | * Applies an accumulator function `f` to each value emitted by the signal. 1085 | * The accumulated value will be emitted for each value emitted by the 1086 | * signal. 1087 | * 1088 | * @param {Function} f The accumulator function to apply to each value 1089 | * emitted by the signal. 1090 | * @param initialValue The initial value. 1091 | * @returns {Signal} A new signal. 1092 | * @example 1093 | * 1094 | * import { Signal } from 'bulb' 1095 | * 1096 | * const s = Signal 1097 | * .of(1, 2, 3) 1098 | * .scan((a, b) => a + b, 0) 1099 | * 1100 | * s.subscribe(console.log) // 1, 3, 6 1101 | */ 1102 | scan (f, initialValue) { 1103 | return new Signal(scan(f, initialValue, this)) 1104 | } 1105 | 1106 | /** 1107 | * Emits the next value from the given values for every value emitted by the 1108 | * signal. The returned signal will complete immediately after the last value 1109 | * has been emitted. 1110 | * 1111 | * @param values The values to emit. 1112 | * @returns {Signal} A new signal. 1113 | * @example 1114 | * 1115 | * import { Signal } from 'bulb' 1116 | * 1117 | * const s = Signal 1118 | * .periodic(1000) 1119 | * .sequential(1, 2, 3) 1120 | * 1121 | * s.subscribe(console.log) // 1, 2, 3 1122 | */ 1123 | sequential (...values) { 1124 | return new Signal(sequential(values, this)) 1125 | } 1126 | 1127 | /** 1128 | * Emits a value before any other values are emitted by the signal. 1129 | * 1130 | * @param value The value to emit. 1131 | * @returns {Signal} A new signal. 1132 | * @example 1133 | * 1134 | * import { Signal } from 'bulb' 1135 | * 1136 | * const s = Signal 1137 | * .of(1, 2, 3) 1138 | * .startWith(0) 1139 | * 1140 | * s.subscribe(console.log) // 0, 1, 2, 3 1141 | */ 1142 | startWith (value) { 1143 | return new Signal(concat([Signal.of(value), this])) 1144 | } 1145 | 1146 | /** 1147 | * Applies a transform function `f` to each value emitted by the signal. 1148 | * 1149 | * The transform function must return a new state, it can also optionally 1150 | * emit values or errors using the `emit` object. 1151 | * 1152 | * @param {Function} f The transform function to apply to each value emitted 1153 | * by the signal. 1154 | * @param initialState The initial state. 1155 | * @returns {Signal} A new signal. 1156 | * @example 1157 | * 1158 | * import { Signal } from 'bulb' 1159 | * 1160 | * const s = Signal 1161 | * .of(1, 2, 3) 1162 | * .stateMachine((a, b, emit) => { 1163 | * emit.next(a + b) 1164 | * return a * b 1165 | * }, 1) 1166 | * 1167 | * s.subscribe(console.log) // 1, 3, 5 1168 | */ 1169 | stateMachine (f, initialState) { 1170 | return new Signal(stateMachine(f, initialState, this)) 1171 | } 1172 | 1173 | /** 1174 | * Subscribes an observer to the signal. 1175 | * 1176 | * The `subscribe` method returns a subscription handle, which can be used to 1177 | * unsubscribe from the signal. 1178 | * 1179 | * @param {Function} [onNext] The callback function called when the signal 1180 | * emits a value. 1181 | * @param {Function} [onError] The callback function called when the signal 1182 | * emits an error. 1183 | * @param {Function} [onComplete] The callback function called when the 1184 | * signal has completed. 1185 | * @returns {Subscription} A subscription handle. 1186 | * @example 1187 | * 1188 | * import { Signal } from 'bulb' 1189 | * 1190 | * const s = Signal.of(1, 2, 3) 1191 | * 1192 | * // Subscribe to the signal and log emitted values to the console. 1193 | * const subscription = s.subscribe(console.log) 1194 | * 1195 | * // When we are done, we can unsubscribe from the signal. 1196 | * subscription.unsubscribe() 1197 | */ 1198 | subscribe (onNext, onError, onComplete) { 1199 | let emit = {} 1200 | 1201 | if (typeof onNext === 'function') { 1202 | emit = { next: onNext, error: onError, complete: onComplete } 1203 | } else if (typeof onNext === 'object') { 1204 | emit = onNext 1205 | } 1206 | 1207 | // Create a new subscription to the signal. 1208 | const subscription = new Subscription(emit, () => { 1209 | // Mark the subsciption as closed. 1210 | subscription.closed = true 1211 | 1212 | // Remove the subscription. 1213 | this._subscriptions.delete(subscription) 1214 | 1215 | // Call the unmount function if we're removing the last subscription. 1216 | if (this._subscriptions.size === 0) { 1217 | this.tryUnmount() 1218 | } 1219 | }) 1220 | 1221 | // Add the subscription. 1222 | this._subscriptions.add(subscription) 1223 | 1224 | // Notifies the observers that a value was emitted. 1225 | const next = broadcast(this._subscriptions, 'next') 1226 | 1227 | // Notifies the observers that an error was emitted. 1228 | const error = broadcast(this._subscriptions, 'error') 1229 | 1230 | // Notifies the observers that the signal has completed and calls the 1231 | // unmount function. 1232 | const complete = () => { 1233 | broadcast(this._subscriptions, 'complete')() 1234 | this.tryUnmount() 1235 | } 1236 | 1237 | // Call the mount function if we're adding the first subscription. 1238 | if (this._subscriptions.size === 1) { 1239 | this.tryMount({ next, error, complete }) 1240 | } 1241 | 1242 | return subscription 1243 | } 1244 | 1245 | /** 1246 | * Subscribes to the most recent signal emitted by the signal (a signal that 1247 | * emits other signals). The returned signal will emit values from the most 1248 | * recent signal. 1249 | * 1250 | * @returns {Signal} A new signal. 1251 | * @example 1252 | * 1253 | * import { Signal } from 'bulb' 1254 | * 1255 | * const s = Signal.of(1) 1256 | * const t = Signal.of(2) 1257 | * const u = Signal 1258 | * .periodic(1000) 1259 | * .sequential(s, t) 1260 | * .switchLatest() 1261 | * 1262 | * u.subscribe(console.log) // 1, 2 1263 | */ 1264 | switchLatest () { 1265 | return new Signal(switchMap(id, this)) 1266 | } 1267 | 1268 | /** 1269 | * Applies a function `f`, that returns a `Signal`, to each value emitted by 1270 | * the signal. The returned signal will emit values from the most recent 1271 | * signal returned by the function. 1272 | * 1273 | * @param {Function} f The function to apply to each value emitted by the 1274 | * signal. It must also return a `Signal`. 1275 | * @returns {Signal} A new signal. 1276 | * @example 1277 | * 1278 | * import { Signal } from 'bulb' 1279 | * 1280 | * const s = Signal 1281 | * .of(1, 2, 3) 1282 | * .switchMap(a => Signal.of(a + 1)) 1283 | * 1284 | * s.subscribe(console.log) // 2, 3, 4 1285 | */ 1286 | switchMap (f) { 1287 | return new Signal(switchMap(f, this)) 1288 | } 1289 | 1290 | /** 1291 | * Takes the first `n` values emitted by the signal, and then completes. 1292 | * 1293 | * @param {Number} n The number of values to take. 1294 | * @returns {Signal} A new signal. 1295 | * @example 1296 | * 1297 | * import { Signal } from 'bulb' 1298 | * 1299 | * const s = Signal 1300 | * .of(1, 2, 3) 1301 | * .take(2) 1302 | * 1303 | * s.subscribe(console.log) // 1, 2 1304 | */ 1305 | take (n) { 1306 | return new Signal(take(n, this)) 1307 | } 1308 | 1309 | /** 1310 | * Emits values from the signal until the given control signal emits a value. 1311 | * The returned signal will complete once the control signal emits a value. 1312 | * 1313 | * @param {Signal} signal The control signal. 1314 | * @returns {Signal} A new signal. 1315 | * @example 1316 | * 1317 | * const s = Signal.periodic(1000) 1318 | * const t = Signal.of().delay(1000) 1319 | * const u = s.takeUntil(t) 1320 | * 1321 | * u.subscribe(console.log) // 0 1322 | */ 1323 | takeUntil (signal) { 1324 | return new Signal(takeUntil(signal, this)) 1325 | } 1326 | 1327 | /** 1328 | * Emits values from the signal while the predicate function `p` is 1329 | * satisfied. The returned signal will complete once the predicate function 1330 | * is not satisfied. 1331 | * 1332 | * @param {Function} p The predicate function to apply to each value emitted 1333 | * by the signal. If it returns `true`, the value will be emitted, otherwise 1334 | * the value will not be emitted. 1335 | * @returns {Signal} A new signal. 1336 | * @example 1337 | * 1338 | * const s = Signal 1339 | * .of(1, 2, 3) 1340 | * .takeWhile(a => a < 2) 1341 | * 1342 | * s.subscribe(console.log) // 1 1343 | */ 1344 | takeWhile (p) { 1345 | return new Signal(takeWhile(p, this)) 1346 | } 1347 | 1348 | /** 1349 | * Performs the side effect function `f` for each value emitted by the 1350 | * signal. The returned signal contains the same events as the original 1351 | * signal. 1352 | * 1353 | * @param {Function} f The function to apply to each value emitted by the 1354 | * signal. 1355 | * @returns {Signal} A new signal. 1356 | * @example 1357 | * 1358 | * import { Signal } from 'bulb' 1359 | * 1360 | * const s = Signal 1361 | * .of(1, 2, 3) 1362 | * .tap(console.log) 1363 | * 1364 | * s.subscribe() // 1, 2, 3 1365 | */ 1366 | tap (f) { 1367 | return new Signal(tap(f, this)) 1368 | } 1369 | 1370 | /** 1371 | * Limits the rate at which values are emitted by the signal to one every `n` 1372 | * milliseconds. Values will be dropped when the rate limit is exceeded. 1373 | * 1374 | * @param {Number} n The number of milliseconds to wait between each value. 1375 | * @returns {Signal} A new signal. 1376 | * @example 1377 | * 1378 | * import { Mouse } from 'bulb-input' 1379 | * 1380 | * const s = Mouse 1381 | * .position(document) 1382 | * .throttle(1000) 1383 | * 1384 | * s.subscribe(console.log) // [1, 1], [2, 2], ... 1385 | */ 1386 | throttle (n) { 1387 | return new Signal(throttle(n, this)) 1388 | } 1389 | 1390 | /** 1391 | * Returns a higher-order signal that emits the windowed values of this 1392 | * signal. 1393 | * 1394 | * @param {Signal} signal The control signal. 1395 | * @returns {Signal} A new signal. 1396 | * @example 1397 | * 1398 | * import { Signal } from 'bulb' 1399 | * 1400 | * const t = Signal.periodic(1000) 1401 | * const s = Signal 1402 | * .of(1, 2, 3, 4) 1403 | * .window(t) 1404 | * .switchLatest() 1405 | * 1406 | * s.subscribe(console.log) // 1, 2, X, 3, 4, X, ... 1407 | */ 1408 | window (signal) { 1409 | return new Signal(window(signal, this)).map(w => new Signal(w)) 1410 | } 1411 | 1412 | /** 1413 | * Combines the corresponding values emitted by the given signals into 1414 | * tuples. The returned signal will complete when *any* of the signals have 1415 | * completed. 1416 | * 1417 | * @param {...Signal} signals The signals to zip. 1418 | * @returns {Signal} A new signal. 1419 | * @example 1420 | * 1421 | * import { Signal } from 'bulb' 1422 | * 1423 | * const s = Signal.of(1, 2, 3) 1424 | * const t = Signal.of(4, 5, 6) 1425 | * const u = s.zip(t) 1426 | * 1427 | * u.subscribe(console.log) // [1, 4], [2, 5], [3, 6] 1428 | */ 1429 | zip (...signals) { 1430 | return new Signal(zipWith(tuple, [this].concat(signals))) 1431 | } 1432 | 1433 | /** 1434 | * Applies the function `f` to the corresponding values emitted by the given 1435 | * signals. The returned signal will complete when *any* of the signals have 1436 | * completed. 1437 | * 1438 | * @param {Function} f The function to apply to the corresponding values 1439 | * emitted by the signals. 1440 | * @param {...Signal} signals The signals to zip. 1441 | * @returns {Signal} A new signal. 1442 | * @example 1443 | * 1444 | * import { Signal } from 'bulb' 1445 | * 1446 | * const s = Signal.of(1, 2, 3) 1447 | * const t = Signal.of(4, 5, 6) 1448 | * const u = s.zipWith((a, b) => a + b, t) 1449 | * 1450 | * u.subscribe(console.log) // 5, 7, 9 1451 | */ 1452 | zipWith (f, ...signals) { 1453 | return new Signal(zipWith(f, [this].concat(signals))) 1454 | } 1455 | 1456 | /** 1457 | * Combines the latest values emitted by the given signals into tuples. The 1458 | * returned signal will complete when *any* of the signals have completed. 1459 | * 1460 | * @param {...Signal} signals The signals to zip. 1461 | * @returns {Signal} A new signal. 1462 | * @example 1463 | * 1464 | * import { Signal } from 'bulb' 1465 | * 1466 | * const s = Signal.of(1, 2, 3) 1467 | * const t = Signal.of(4, 5, 6) 1468 | * const u = s.zipLatest(t) 1469 | * 1470 | * u.subscribe(console.log) // [1, 4], [2, 5], [3, 6] 1471 | */ 1472 | zipLatest (...signals) { 1473 | return new Signal(zipLatestWith(tuple, [this].concat(signals))) 1474 | } 1475 | 1476 | /** 1477 | * Applies the function `f` to the latest values emitted by the given signals. 1478 | * The returned signal will complete when *any* of the signals have completed. 1479 | * 1480 | * @param {Function} f The function to apply to the corresponding values 1481 | * emitted by the signals. 1482 | * @param {...Signal} signals The signals to zip. 1483 | * @returns {Signal} A new signal. 1484 | * @example 1485 | * 1486 | * import { Signal } from 'bulb' 1487 | * 1488 | * const s = Signal.of(1, 2, 3) 1489 | * const t = Signal.of(4, 5, 6) 1490 | * const u = s.zipLatestWith((a, b) => a + b, t) 1491 | * 1492 | * u.subscribe(console.log) // 5, 7, 9 1493 | */ 1494 | zipLatestWith (f, ...signals) { 1495 | return new Signal(zipLatestWith(f, [this].concat(signals))) 1496 | } 1497 | } 1498 | -------------------------------------------------------------------------------- /packages/bulb/src/Signal.test.js: -------------------------------------------------------------------------------- 1 | import eq from 'fkit/dist/_eq' 2 | import events from 'events' 3 | import id from 'fkit/dist/id' 4 | import tuple from 'fkit/dist/tuple' 5 | 6 | import { Signal } from './Signal' 7 | import all from './combinators/all' 8 | import always from './combinators/always' 9 | import any from './combinators/any' 10 | import apply from './combinators/apply' 11 | import buffer from './combinators/buffer' 12 | import bufferWith from './combinators/bufferWith' 13 | import catchError from './combinators/catchError' 14 | import concat from './combinators/concat' 15 | import concatMap from './combinators/concatMap' 16 | import cycle from './combinators/cycle' 17 | import debounce from './combinators/debounce' 18 | import dedupeWith from './combinators/dedupeWith' 19 | import delay from './combinators/delay' 20 | import drop from './combinators/drop' 21 | import dropUntil from './combinators/dropUntil' 22 | import dropWhile from './combinators/dropWhile' 23 | import map from './combinators/map' 24 | import merge from './combinators/merge' 25 | import mockSignal from './internal/mockSignal' 26 | import sample from './combinators/sample' 27 | import scan from './combinators/scan' 28 | import sequential from './combinators/sequential' 29 | import stateMachine from './combinators/stateMachine' 30 | import switchMap from './combinators/switchMap' 31 | import take from './combinators/take' 32 | import takeUntil from './combinators/takeUntil' 33 | import takeWhile from './combinators/takeWhile' 34 | import tap from './combinators/tap' 35 | import throttle from './combinators/throttle' 36 | import zipWith from './combinators/zipWith' 37 | import { asap } from './scheduler' 38 | 39 | jest.mock('./combinators/all', () => jest.fn(() => () => {})) 40 | jest.mock('./combinators/always', () => jest.fn(() => () => {})) 41 | jest.mock('./combinators/any', () => jest.fn(() => () => {})) 42 | jest.mock('./combinators/apply', () => jest.fn(() => () => {})) 43 | jest.mock('./combinators/buffer', () => jest.fn(() => () => {})) 44 | jest.mock('./combinators/bufferWith', () => jest.fn(() => () => {})) 45 | jest.mock('./combinators/catchError', () => jest.fn(() => () => {})) 46 | jest.mock('./combinators/concat', () => jest.fn(() => () => {})) 47 | jest.mock('./combinators/concatMap', () => jest.fn(() => () => {})) 48 | jest.mock('./combinators/cycle', () => jest.fn(() => () => {})) 49 | jest.mock('./combinators/debounce', () => jest.fn(() => () => {})) 50 | jest.mock('./combinators/dedupeWith', () => jest.fn(() => () => {})) 51 | jest.mock('./combinators/delay', () => jest.fn(() => () => {})) 52 | jest.mock('./combinators/drop', () => jest.fn(() => () => {})) 53 | jest.mock('./combinators/dropUntil', () => jest.fn(() => () => {})) 54 | jest.mock('./combinators/dropWhile', () => jest.fn(() => () => {})) 55 | jest.mock('./combinators/map', () => jest.fn(() => () => {})) 56 | jest.mock('./combinators/merge', () => jest.fn(() => () => {})) 57 | jest.mock('./combinators/sample', () => jest.fn(() => () => {})) 58 | jest.mock('./combinators/scan', () => jest.fn(() => () => {})) 59 | jest.mock('./combinators/sequential', () => jest.fn(() => () => {})) 60 | jest.mock('./combinators/stateMachine', () => jest.fn(() => () => {})) 61 | jest.mock('./combinators/switchMap', () => jest.fn(() => () => {})) 62 | jest.mock('./combinators/take', () => jest.fn(() => () => {})) 63 | jest.mock('./combinators/takeUntil', () => jest.fn(() => () => {})) 64 | jest.mock('./combinators/takeWhile', () => jest.fn(() => () => {})) 65 | jest.mock('./combinators/tap', () => jest.fn(() => () => {})) 66 | jest.mock('./combinators/throttle', () => jest.fn(() => () => {})) 67 | jest.mock('./combinators/zipWith', () => jest.fn(() => () => {})) 68 | jest.mock('./scheduler') 69 | 70 | let nextSpy, errorSpy, completeSpy 71 | 72 | describe('Signal', () => { 73 | beforeEach(() => { 74 | nextSpy = jest.fn() 75 | errorSpy = jest.fn() 76 | completeSpy = jest.fn() 77 | asap.mockImplementation(f => f()) 78 | }) 79 | 80 | describe('.concat', () => { 81 | it('calls the combinator', () => { 82 | const s = mockSignal() 83 | const t = mockSignal() 84 | const u = mockSignal() 85 | 86 | Signal.concat(s, t, u) 87 | 88 | expect(concat).toHaveBeenLastCalledWith([s, t, u]) 89 | }) 90 | }) 91 | 92 | describe('.empty', () => { 93 | it('returns a signal that has already completed', () => { 94 | const s = Signal.empty() 95 | 96 | s.subscribe(nextSpy, errorSpy, completeSpy) 97 | 98 | expect(nextSpy).not.toHaveBeenCalled() 99 | expect(errorSpy).not.toHaveBeenCalled() 100 | expect(completeSpy).toHaveBeenCalled() 101 | }) 102 | }) 103 | 104 | describe('.from', () => { 105 | it('returns a signal that emits values from an array', () => { 106 | const s = Signal.from([1, 2, 3]) 107 | 108 | s.subscribe(nextSpy, errorSpy, completeSpy) 109 | 110 | expect(nextSpy).toHaveBeenCalledTimes(3) 111 | expect(nextSpy).toHaveBeenNthCalledWith(1, 1) 112 | expect(nextSpy).toHaveBeenNthCalledWith(2, 2) 113 | expect(nextSpy).toHaveBeenNthCalledWith(3, 3) 114 | expect(completeSpy).toHaveBeenCalled() 115 | }) 116 | 117 | it('returns a signal that emits values from a string', () => { 118 | const s = Signal.from('foo') 119 | 120 | s.subscribe(nextSpy, errorSpy, completeSpy) 121 | 122 | expect(nextSpy).toHaveBeenCalledTimes(3) 123 | expect(nextSpy).toHaveBeenNthCalledWith(1, 'f') 124 | expect(nextSpy).toHaveBeenNthCalledWith(2, 'o') 125 | expect(nextSpy).toHaveBeenNthCalledWith(3, 'o') 126 | expect(completeSpy).toHaveBeenCalled() 127 | }) 128 | }) 129 | 130 | describe('.fromCallback', () => { 131 | it('returns a signal that wraps a callback', () => { 132 | let next 133 | const s = Signal.fromCallback(callback => { 134 | next = a => { callback(null, a) } 135 | }) 136 | 137 | s.subscribe(nextSpy, errorSpy, completeSpy) 138 | 139 | next(1) 140 | expect(nextSpy).toHaveBeenLastCalledWith(1) 141 | next(2) 142 | expect(nextSpy).toHaveBeenLastCalledWith(2) 143 | next(3) 144 | expect(nextSpy).toHaveBeenLastCalledWith(3) 145 | expect(completeSpy).not.toHaveBeenCalled() 146 | }) 147 | 148 | it('calls the unmount function when the last observer unsubscribes', () => { 149 | const unmount = jest.fn() 150 | const s = Signal.fromCallback(callback => unmount) 151 | const a = s.subscribe(nextSpy, errorSpy, completeSpy) 152 | 153 | expect(unmount).not.toHaveBeenCalled() 154 | a.unsubscribe() 155 | expect(unmount).toHaveBeenCalled() 156 | }) 157 | }) 158 | 159 | describe('.fromEvent', () => { 160 | it('returns a signal that emits events', () => { 161 | const emitter = new events.EventEmitter() 162 | const s = Signal.fromEvent('lol', emitter) 163 | 164 | s.subscribe(nextSpy, errorSpy, completeSpy) 165 | 166 | emitter.emit('lol', 1) 167 | expect(nextSpy).toHaveBeenLastCalledWith(1) 168 | emitter.emit('lol', 2) 169 | expect(nextSpy).toHaveBeenLastCalledWith(2) 170 | emitter.emit('lol', 3) 171 | expect(nextSpy).toHaveBeenLastCalledWith(3) 172 | expect(completeSpy).not.toHaveBeenCalled() 173 | }) 174 | }) 175 | 176 | describe('.fromPromise', () => { 177 | it('returns a signal that wraps a promise', () => { 178 | let next, complete 179 | 180 | const s = Signal.fromPromise({ 181 | then: onFulfilled => { 182 | next = onFulfilled 183 | return { 184 | finally: onFinally => { complete = onFinally } 185 | } 186 | } 187 | }) 188 | 189 | s.subscribe(nextSpy, errorSpy, completeSpy) 190 | 191 | next(1) 192 | expect(nextSpy).toHaveBeenLastCalledWith(1) 193 | next(2) 194 | expect(nextSpy).toHaveBeenLastCalledWith(2) 195 | next(3) 196 | expect(nextSpy).toHaveBeenLastCalledWith(3) 197 | complete() 198 | expect(completeSpy).toHaveBeenCalled() 199 | }) 200 | }) 201 | 202 | describe('.merge', () => { 203 | it('calls the combinator', () => { 204 | const s = mockSignal() 205 | const t = mockSignal() 206 | const u = mockSignal() 207 | 208 | Signal.merge(s, t, u) 209 | 210 | expect(merge).toHaveBeenLastCalledWith([s, t, u]) 211 | }) 212 | }) 213 | 214 | describe('.never', () => { 215 | it('returns a signal that never completes', () => { 216 | const s = Signal.never() 217 | 218 | s.subscribe(nextSpy, errorSpy, completeSpy) 219 | 220 | expect(nextSpy).not.toHaveBeenCalled() 221 | expect(errorSpy).not.toHaveBeenCalled() 222 | expect(completeSpy).not.toHaveBeenCalled() 223 | }) 224 | }) 225 | 226 | describe('.of', () => { 227 | it('returns a signal that emits the given values', () => { 228 | const s = Signal.of(1, 2, 3) 229 | 230 | s.subscribe(nextSpy, errorSpy, completeSpy) 231 | 232 | expect(nextSpy).toHaveBeenNthCalledWith(1, 1) 233 | expect(nextSpy).toHaveBeenNthCalledWith(2, 2) 234 | expect(nextSpy).toHaveBeenNthCalledWith(3, 3) 235 | expect(completeSpy).toHaveBeenCalled() 236 | }) 237 | }) 238 | 239 | describe('.periodic', () => { 240 | it('returns a signal that periodically emits a value', () => { 241 | jest.useFakeTimers() 242 | 243 | const spy = jest.fn() 244 | const s = Signal.periodic(1000) 245 | 246 | s.subscribe(spy) 247 | 248 | jest.advanceTimersByTime(1000) 249 | expect(spy).toHaveBeenLastCalledWith(0) 250 | jest.advanceTimersByTime(1000) 251 | expect(spy).toHaveBeenLastCalledWith(1) 252 | jest.advanceTimersByTime(1000) 253 | expect(spy).toHaveBeenLastCalledWith(2) 254 | 255 | jest.useRealTimers() 256 | }) 257 | }) 258 | 259 | describe('.throwError', () => { 260 | it('returns a signal that throws an error', () => { 261 | const s = Signal.throwError('foo') 262 | 263 | s.subscribe(nextSpy, errorSpy, completeSpy) 264 | 265 | expect(errorSpy).toHaveBeenLastCalledWith('foo') 266 | expect(completeSpy).toHaveBeenCalled() 267 | }) 268 | }) 269 | 270 | describe('.zip', () => { 271 | it('calls the combinator', () => { 272 | const s = mockSignal() 273 | const t = mockSignal() 274 | const u = mockSignal() 275 | 276 | Signal.zip(s, t, u) 277 | 278 | expect(zipWith).toHaveBeenLastCalledWith(tuple, [s, t, u]) 279 | }) 280 | }) 281 | 282 | describe('.zipWith', () => { 283 | it('calls the combinator', () => { 284 | const s = mockSignal() 285 | const t = mockSignal() 286 | const u = mockSignal() 287 | const f = jest.fn() 288 | 289 | Signal.zipWith(f, s, t, u) 290 | 291 | expect(zipWith).toHaveBeenLastCalledWith(f, [s, t, u]) 292 | }) 293 | }) 294 | 295 | describe('#all', () => { 296 | it('calls the combinator', () => { 297 | const p = jest.fn() 298 | const s = mockSignal() 299 | 300 | s.all(p) 301 | 302 | expect(all).toHaveBeenLastCalledWith(p, s) 303 | }) 304 | }) 305 | 306 | describe('#always', () => { 307 | it('calls the combinator', () => { 308 | const s = mockSignal() 309 | 310 | s.always(1) 311 | 312 | expect(always).toHaveBeenLastCalledWith(1, s) 313 | }) 314 | }) 315 | 316 | describe('#any', () => { 317 | it('calls the combinator', () => { 318 | const p = jest.fn() 319 | const s = mockSignal() 320 | 321 | s.any(p) 322 | 323 | expect(any).toHaveBeenLastCalledWith(p, s) 324 | }) 325 | }) 326 | 327 | describe('#append', () => { 328 | it('calls the combinator', () => { 329 | const s = mockSignal() 330 | const t = mockSignal() 331 | const spy = jest.spyOn(Signal, 'from').mockReturnValue(t) 332 | 333 | s.append(1, 2, 3) 334 | 335 | expect(concat).toHaveBeenLastCalledWith([s, t]) 336 | expect(spy).toHaveBeenLastCalledWith([1, 2, 3]) 337 | }) 338 | }) 339 | 340 | describe('#apply', () => { 341 | it('calls the combinator', () => { 342 | const s = mockSignal() 343 | const t = mockSignal() 344 | const u = mockSignal() 345 | 346 | s.apply(t, u) 347 | 348 | expect(apply).toHaveBeenLastCalledWith(s, [t, u]) 349 | }) 350 | }) 351 | 352 | describe('#buffer', () => { 353 | it('calls the combinator', () => { 354 | const s = mockSignal() 355 | 356 | s.buffer(1) 357 | 358 | expect(buffer).toHaveBeenLastCalledWith(1, s) 359 | }) 360 | }) 361 | 362 | describe('#bufferWith', () => { 363 | it('calls the combinator', () => { 364 | const s = mockSignal() 365 | const t = mockSignal() 366 | 367 | s.bufferWith(t) 368 | 369 | expect(bufferWith).toHaveBeenLastCalledWith(t, s) 370 | }) 371 | }) 372 | 373 | describe('#catchError', () => { 374 | it('calls the combinator', () => { 375 | const f = jest.fn() 376 | const s = mockSignal() 377 | 378 | s.catchError(f) 379 | 380 | expect(catchError).toHaveBeenLastCalledWith(f, s) 381 | }) 382 | }) 383 | 384 | describe('#concat', () => { 385 | it('calls the combinator', () => { 386 | const s = mockSignal() 387 | const t = mockSignal() 388 | const u = mockSignal() 389 | 390 | s.concat(t, u) 391 | 392 | expect(concat).toHaveBeenLastCalledWith([s, t, u]) 393 | }) 394 | }) 395 | 396 | describe('#concatMap', () => { 397 | it('calls the combinator', () => { 398 | const f = jest.fn() 399 | const s = mockSignal() 400 | 401 | s.concatMap(f) 402 | 403 | expect(concatMap).toHaveBeenLastCalledWith(f, s) 404 | }) 405 | }) 406 | 407 | describe('#cycle', () => { 408 | it('calls the combinator', () => { 409 | const s = mockSignal() 410 | 411 | s.cycle(1, 2, 3) 412 | 413 | expect(cycle).toHaveBeenLastCalledWith([1, 2, 3], s) 414 | }) 415 | }) 416 | 417 | describe('#debounce', () => { 418 | it('calls the combinator', () => { 419 | const s = mockSignal() 420 | 421 | s.debounce(1) 422 | 423 | expect(debounce).toHaveBeenLastCalledWith(1, s) 424 | }) 425 | }) 426 | 427 | describe('#dedupe', () => { 428 | it('calls the combinator', () => { 429 | const s = mockSignal() 430 | 431 | s.dedupe() 432 | 433 | expect(dedupeWith).toHaveBeenLastCalledWith(eq, s) 434 | }) 435 | }) 436 | 437 | describe('#dedupeWith', () => { 438 | it('calls the combinator', () => { 439 | const f = jest.fn() 440 | const s = mockSignal() 441 | 442 | s.dedupeWith(f) 443 | 444 | expect(dedupeWith).toHaveBeenLastCalledWith(f, s) 445 | }) 446 | }) 447 | 448 | describe('#delay', () => { 449 | it('calls the combinator', () => { 450 | const s = mockSignal() 451 | 452 | s.delay(1) 453 | 454 | expect(delay).toHaveBeenLastCalledWith(1, s) 455 | }) 456 | }) 457 | 458 | describe('#drop', () => { 459 | it('calls the combinator', () => { 460 | const s = mockSignal() 461 | 462 | s.drop(1) 463 | 464 | expect(drop).toHaveBeenLastCalledWith(1, s) 465 | }) 466 | }) 467 | 468 | describe('#dropUntil', () => { 469 | it('calls the combinator', () => { 470 | const s = mockSignal() 471 | const t = mockSignal() 472 | 473 | s.dropUntil(t) 474 | 475 | expect(dropUntil).toHaveBeenLastCalledWith(t, s) 476 | }) 477 | }) 478 | 479 | describe('#dropWhile', () => { 480 | it('calls the combinator', () => { 481 | const p = jest.fn() 482 | const s = mockSignal() 483 | 484 | s.dropWhile(p) 485 | 486 | expect(dropWhile).toHaveBeenLastCalledWith(p, s) 487 | }) 488 | }) 489 | 490 | describe('#encode', () => { 491 | it('calls the combinator', () => { 492 | const s = mockSignal() 493 | const t = mockSignal() 494 | const u = mockSignal() 495 | 496 | s.encode(t, u) 497 | 498 | expect(map).toHaveBeenLastCalledWith(expect.any(Function), s) 499 | }) 500 | }) 501 | 502 | describe('#endWith', () => { 503 | it('calls the combinator', () => { 504 | const s = mockSignal() 505 | const t = mockSignal() 506 | const spy = jest.spyOn(Signal, 'of').mockReturnValue(t) 507 | 508 | s.endWith(1) 509 | 510 | expect(spy).toHaveBeenLastCalledWith(1) 511 | expect(concat).toHaveBeenLastCalledWith([s, t]) 512 | }) 513 | }) 514 | 515 | describe('#first', () => { 516 | it('calls the combinator', () => { 517 | const s = mockSignal() 518 | 519 | s.first() 520 | 521 | expect(take).toHaveBeenLastCalledWith(1, s) 522 | }) 523 | }) 524 | 525 | describe('#last', () => { 526 | it('emits the last value', () => { 527 | const s = mockSignal() 528 | 529 | s.last().subscribe(nextSpy, errorSpy, completeSpy) 530 | 531 | s.next(1) 532 | s.next(2) 533 | s.next(3) 534 | s.complete() 535 | expect(nextSpy).toHaveBeenCalledTimes(1) 536 | expect(nextSpy).toHaveBeenLastCalledWith(3) 537 | }) 538 | }) 539 | 540 | describe('#map', () => { 541 | it('calls the combinator', () => { 542 | const f = jest.fn() 543 | const s = mockSignal() 544 | 545 | s.map(f) 546 | 547 | expect(map).toHaveBeenLastCalledWith(f, s) 548 | }) 549 | }) 550 | 551 | describe('#merge', () => { 552 | it('calls the combinator', () => { 553 | const s = mockSignal() 554 | const t = mockSignal() 555 | const u = mockSignal() 556 | 557 | s.merge(t, u) 558 | 559 | expect(merge).toHaveBeenLastCalledWith([s, t, u]) 560 | }) 561 | }) 562 | 563 | describe('#prepend', () => { 564 | it('calls the combinator', () => { 565 | const s = mockSignal() 566 | const t = mockSignal() 567 | const spy = jest.spyOn(Signal, 'from').mockReturnValue(t) 568 | 569 | s.prepend(1, 2, 3) 570 | 571 | expect(spy).toHaveBeenLastCalledWith([1, 2, 3]) 572 | expect(concat).toHaveBeenLastCalledWith([t, s]) 573 | }) 574 | }) 575 | 576 | describe('#sample', () => { 577 | it('calls the combinator', () => { 578 | const s = mockSignal() 579 | const t = mockSignal() 580 | 581 | s.sample(t) 582 | 583 | expect(sample).toHaveBeenLastCalledWith(t, s) 584 | }) 585 | }) 586 | 587 | describe('#scan', () => { 588 | it('calls the combinator', () => { 589 | const f = jest.fn() 590 | const s = mockSignal() 591 | 592 | s.scan(f, 1) 593 | 594 | expect(scan).toHaveBeenLastCalledWith(f, 1, s) 595 | }) 596 | }) 597 | 598 | describe('#sequential', () => { 599 | it('calls the combinator', () => { 600 | const s = mockSignal() 601 | 602 | s.sequential(1, 2, 3) 603 | 604 | expect(sequential).toHaveBeenLastCalledWith([1, 2, 3], s) 605 | }) 606 | }) 607 | 608 | describe('#startWith', () => { 609 | it('calls the combinator', () => { 610 | const s = mockSignal() 611 | const t = mockSignal() 612 | const spy = jest.spyOn(Signal, 'of').mockReturnValue(t) 613 | 614 | s.startWith(1) 615 | 616 | expect(spy).toHaveBeenLastCalledWith(1) 617 | expect(concat).toHaveBeenLastCalledWith([t, s]) 618 | }) 619 | }) 620 | 621 | describe('#stateMachine', () => { 622 | it('calls the combinator', () => { 623 | const f = jest.fn() 624 | const s = mockSignal() 625 | 626 | s.stateMachine(f, 1) 627 | 628 | expect(stateMachine).toHaveBeenLastCalledWith(f, 1, s) 629 | }) 630 | }) 631 | 632 | describe('#subscribe', () => { 633 | it('calls the mount function when the first observer subscribes', () => { 634 | const mount = jest.fn() 635 | const s = new Signal(mount) 636 | 637 | s.subscribe() 638 | expect(mount).toHaveBeenCalled() 639 | s.subscribe() 640 | expect(mount).toHaveBeenCalledTimes(1) 641 | }) 642 | 643 | it('calls the unmount function when the last observer unsubscribes', () => { 644 | const s = mockSignal() 645 | const a = s.subscribe() 646 | const b = s.subscribe() 647 | 648 | a.unsubscribe() 649 | expect(s.unmount).not.toHaveBeenCalled() 650 | b.unsubscribe() 651 | expect(s.unmount).toHaveBeenCalledTimes(1) 652 | }) 653 | 654 | it('only calls the unmount function once', () => { 655 | const s = mockSignal() 656 | const a = s.subscribe() 657 | 658 | a.unsubscribe() 659 | a.unsubscribe() 660 | expect(s.unmount).toHaveBeenCalledTimes(1) 661 | }) 662 | 663 | it('marks the subscriber as closed when they unsubscribe', () => { 664 | const s = mockSignal() 665 | const a = s.subscribe() 666 | 667 | expect(a.closed).toBe(false) 668 | a.unsubscribe() 669 | expect(a.closed).toBe(true) 670 | }) 671 | 672 | it('calls the unmount function when the signal is complete', () => { 673 | const s = mockSignal() 674 | 675 | s.subscribe() 676 | 677 | expect(s.unmount).not.toHaveBeenCalled() 678 | s.complete() 679 | expect(s.unmount).toHaveBeenCalledTimes(1) 680 | }) 681 | 682 | it('calls the value callback when the signal emits a value', () => { 683 | const s = mockSignal() 684 | 685 | s.subscribe(nextSpy, errorSpy, completeSpy) 686 | 687 | expect(nextSpy).not.toHaveBeenCalled() 688 | s.next('foo') 689 | expect(nextSpy).toHaveBeenLastCalledWith('foo') 690 | }) 691 | 692 | it('calls the error callback when the signal emits an error', () => { 693 | const s = mockSignal() 694 | 695 | s.subscribe(nextSpy, errorSpy, completeSpy) 696 | 697 | expect(errorSpy).not.toHaveBeenCalled() 698 | s.error('foo') 699 | expect(errorSpy).toHaveBeenLastCalledWith('foo') 700 | }) 701 | 702 | it('calls the complete callback when the signal has completed', () => { 703 | const s = mockSignal() 704 | 705 | s.subscribe(nextSpy, errorSpy, completeSpy) 706 | 707 | expect(completeSpy).not.toHaveBeenCalled() 708 | s.complete() 709 | expect(completeSpy).toHaveBeenCalledTimes(1) 710 | }) 711 | }) 712 | 713 | describe('#switchLatest', () => { 714 | it('calls the combinator', () => { 715 | const s = mockSignal() 716 | 717 | s.switchLatest() 718 | 719 | expect(switchMap).toHaveBeenLastCalledWith(id, s) 720 | }) 721 | }) 722 | 723 | describe('#take', () => { 724 | it('calls the combinator', () => { 725 | const s = mockSignal() 726 | 727 | s.take(1) 728 | 729 | expect(take).toHaveBeenLastCalledWith(1, s) 730 | }) 731 | }) 732 | 733 | describe('#takeUntil', () => { 734 | it('calls the combinator', () => { 735 | const s = mockSignal() 736 | const t = mockSignal() 737 | 738 | s.takeUntil(t) 739 | 740 | expect(takeUntil).toHaveBeenLastCalledWith(t, s) 741 | }) 742 | }) 743 | 744 | describe('#takeWhile', () => { 745 | it('calls the combinator', () => { 746 | const p = jest.fn() 747 | const s = mockSignal() 748 | 749 | s.takeWhile(p) 750 | 751 | expect(takeWhile).toHaveBeenLastCalledWith(p, s) 752 | }) 753 | }) 754 | 755 | describe('#tap', () => { 756 | it('calls the combinator', () => { 757 | const f = jest.fn() 758 | const s = mockSignal() 759 | 760 | s.tap(f) 761 | 762 | expect(tap).toHaveBeenLastCalledWith(f, s) 763 | }) 764 | }) 765 | 766 | describe('#throttle', () => { 767 | it('calls the combinator', () => { 768 | const s = mockSignal() 769 | 770 | s.throttle(1) 771 | 772 | expect(throttle).toHaveBeenLastCalledWith(1, s) 773 | }) 774 | }) 775 | 776 | describe('#zip', () => { 777 | it('calls the combinator', () => { 778 | const s = mockSignal() 779 | const t = mockSignal() 780 | const u = mockSignal() 781 | 782 | s.zip(t, u) 783 | 784 | expect(zipWith).toHaveBeenLastCalledWith(tuple, [s, t, u]) 785 | }) 786 | }) 787 | 788 | describe('#zipWith', () => { 789 | it('calls the combinator', () => { 790 | const f = jest.fn() 791 | const s = mockSignal() 792 | const t = mockSignal() 793 | const u = mockSignal() 794 | 795 | s.zipWith(f, t, u) 796 | 797 | expect(zipWith).toHaveBeenLastCalledWith(f, [s, t, u]) 798 | }) 799 | }) 800 | }) 801 | -------------------------------------------------------------------------------- /packages/bulb/src/Subscription.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The `Subscription` class represents an observer who has subscribed to 3 | * a `Signal`. 4 | * 5 | * @param {Object} emit An emit object. 6 | * @param {Function} unsubscribe An unsubscribe function. 7 | */ 8 | class Subscription { 9 | constructor (emit, unsubscribe) { 10 | this.emit = emit 11 | 12 | /** 13 | * Unsubscribes the observer from the signal. 14 | * 15 | * @function 16 | */ 17 | this.unsubscribe = unsubscribe 18 | 19 | /** 20 | * @returns {Boolean} A boolean indicating whether the subscription is closed. 21 | */ 22 | this.closed = false 23 | } 24 | } 25 | 26 | export default Subscription 27 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/all.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Emits `true` if *all* the values emitted by the signal `s` satisfy a 3 | * predicate function `p`. 4 | * 5 | * @private 6 | */ 7 | export default function all (p, s) { 8 | return emit => { 9 | let result = true 10 | 11 | const subscription = s.subscribe({ 12 | ...emit, 13 | next (a) { 14 | result = result && p(a) 15 | if (!result) { this.complete() } 16 | }, 17 | complete () { 18 | emit.next(result) 19 | emit.complete() 20 | } 21 | }) 22 | 23 | return () => subscription.unsubscribe() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/all.test.js: -------------------------------------------------------------------------------- 1 | import { id } from 'fkit' 2 | 3 | import all from './all' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('all', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('emits true when all the values emitted by the given signal satisfy the predicate function', () => { 22 | const f = jest.fn(id) 23 | 24 | all(f, s)(emit) 25 | 26 | s.next(true) 27 | expect(next).not.toHaveBeenCalled() 28 | s.complete() 29 | expect(next).toHaveBeenCalledTimes(1) 30 | expect(next).toHaveBeenCalledWith(true) 31 | }) 32 | 33 | it('emits false when any value emitted by the given signal doesn\'t satisfy the predicate function', () => { 34 | const f = jest.fn(id) 35 | 36 | all(f, s)(emit) 37 | 38 | expect(next).not.toHaveBeenCalled() 39 | s.next(false) 40 | expect(next).toHaveBeenCalledTimes(1) 41 | expect(next).toHaveBeenCalledWith(false) 42 | }) 43 | 44 | it('emits an error when the given signal emits an error', () => { 45 | all(id, s)(emit) 46 | 47 | expect(error).not.toHaveBeenCalled() 48 | s.error('foo') 49 | expect(error).toHaveBeenCalledTimes(1) 50 | expect(error).toHaveBeenCalledWith('foo') 51 | }) 52 | 53 | it('completes when the given signal is completed', () => { 54 | all(id, s)(emit) 55 | 56 | expect(complete).not.toHaveBeenCalled() 57 | s.complete() 58 | expect(complete).toHaveBeenCalledTimes(1) 59 | }) 60 | 61 | it('completes when the predicate function is unsatisfied', () => { 62 | const f = jest.fn(id) 63 | 64 | all(f, s)(emit) 65 | 66 | expect(complete).not.toHaveBeenCalled() 67 | s.next(false) 68 | expect(complete).toHaveBeenCalledTimes(1) 69 | }) 70 | 71 | it('unmounts the given signal when the unsubscribe function is called', () => { 72 | const unsubscribe = all(id, s)() 73 | 74 | expect(s.unmount).not.toHaveBeenCalled() 75 | unsubscribe() 76 | expect(s.unmount).toHaveBeenCalledTimes(1) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/always.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Replaces the values of the signal `s` with a constant `c`. 3 | * 4 | * @private 5 | */ 6 | export default function always (c, s) { 7 | return emit => { 8 | const subscription = s.subscribe({ 9 | ...emit, 10 | next () { emit.next(c) } 11 | }) 12 | 13 | return () => subscription.unsubscribe() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/always.test.js: -------------------------------------------------------------------------------- 1 | import always from './always' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s 5 | let next, error, complete 6 | let emit 7 | 8 | describe('always', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | 12 | next = jest.fn() 13 | error = jest.fn() 14 | complete = jest.fn() 15 | 16 | emit = { next, error, complete } 17 | }) 18 | 19 | it('replaces signal values with a constant', () => { 20 | always(0, s)(emit) 21 | 22 | expect(next).not.toHaveBeenCalled() 23 | s.next(1) 24 | expect(next).toHaveBeenCalledTimes(1) 25 | expect(next).toHaveBeenCalledWith(0) 26 | s.next(2) 27 | expect(next).toHaveBeenCalledTimes(2) 28 | expect(next).toHaveBeenCalledWith(0) 29 | s.next(3) 30 | expect(next).toHaveBeenCalledTimes(3) 31 | expect(next).toHaveBeenCalledWith(0) 32 | }) 33 | 34 | it('emits an error when the given signal emits an error', () => { 35 | always(0, s)(emit) 36 | 37 | expect(error).not.toHaveBeenCalled() 38 | s.error('foo') 39 | expect(error).toHaveBeenCalledTimes(1) 40 | expect(error).toHaveBeenCalledWith('foo') 41 | }) 42 | 43 | it('completes when the given signal is completed', () => { 44 | always(0, s)(emit) 45 | 46 | expect(complete).not.toHaveBeenCalled() 47 | s.complete() 48 | expect(complete).toHaveBeenCalledTimes(1) 49 | }) 50 | 51 | it('unmounts the given signal when the unsubscribe function is called', () => { 52 | const unsubscribe = always(0, s)(emit) 53 | 54 | expect(s.unmount).not.toHaveBeenCalled() 55 | unsubscribe() 56 | expect(s.unmount).toHaveBeenCalledTimes(1) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/any.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Emits `true` if *any* of the values emitted by the signal `s` satisfy a 3 | * predicate function `p`. 4 | * 5 | * @private 6 | */ 7 | export default function any (p, s) { 8 | return emit => { 9 | let result = false 10 | 11 | const subscription = s.subscribe({ 12 | ...emit, 13 | next (a) { 14 | result = result || p(a) 15 | if (result) { this.complete() } 16 | }, 17 | complete () { 18 | emit.next(result) 19 | emit.complete() 20 | } 21 | }) 22 | 23 | return () => subscription.unsubscribe() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/any.test.js: -------------------------------------------------------------------------------- 1 | import { id } from 'fkit' 2 | 3 | import any from './any' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('any', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('emits true when any value emitted by the given signal satisfies the predicate function', () => { 22 | const f = jest.fn(id) 23 | 24 | any(f, s)(emit) 25 | 26 | s.next(false) 27 | expect(next).not.toHaveBeenCalled() 28 | s.next(true) 29 | expect(next).toHaveBeenCalledTimes(1) 30 | expect(next).toHaveBeenCalledWith(true) 31 | }) 32 | 33 | it('emits false when none of the values emitted by the given signal satisfy the predicate function', () => { 34 | const f = jest.fn(id) 35 | 36 | any(f, s)(emit) 37 | 38 | s.next(false) 39 | expect(next).not.toHaveBeenCalled() 40 | s.complete() 41 | expect(next).toHaveBeenCalledTimes(1) 42 | expect(next).toHaveBeenCalledWith(false) 43 | }) 44 | 45 | it('emits an error when the given signal emits an error', () => { 46 | any(id, s)(emit) 47 | 48 | expect(error).not.toHaveBeenCalled() 49 | s.error('foo') 50 | expect(error).toHaveBeenCalledTimes(1) 51 | expect(error).toHaveBeenCalledWith('foo') 52 | }) 53 | 54 | it('completes when the given signal is completed', () => { 55 | any(id, s)(emit) 56 | 57 | expect(complete).not.toHaveBeenCalled() 58 | s.complete() 59 | expect(complete).toHaveBeenCalledTimes(1) 60 | }) 61 | 62 | it('completes when the predicate function is satisfied', () => { 63 | const f = jest.fn(id) 64 | 65 | any(f, s)(emit) 66 | 67 | expect(complete).not.toHaveBeenCalled() 68 | s.next(true) 69 | expect(complete).toHaveBeenCalledTimes(1) 70 | }) 71 | 72 | it('unmounts the given signal when the returned function is called', () => { 73 | const unsubscribe = any(id, s)(emit) 74 | 75 | expect(s.unmount).not.toHaveBeenCalled() 76 | unsubscribe() 77 | expect(s.unmount).toHaveBeenCalledTimes(1) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/apply.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies the latest function emitted by the signal `s` to latest values 3 | * emitted by the signals `ts`. 4 | * 5 | * @private 6 | */ 7 | export default function apply (s, ts) { 8 | return emit => { 9 | const buffer = new Array(ts.length) 10 | let f 11 | let enabled = false 12 | let completed = false 13 | let nextMask = 0 14 | let completeMask = 0 15 | 16 | // Checks whether all mask bits are set 17 | const checkMask = mask => mask === (1 << ts.length) - 1 18 | 19 | // Emits the next value if all signals are enabled 20 | const tryNext = () => { 21 | enabled ||= checkMask(nextMask) 22 | if (f && enabled) { emit.next(f(...buffer)) } 23 | } 24 | 25 | // Emits a complete event if all signals are completed 26 | const tryComplete = () => { 27 | completed ||= checkMask(completeMask) 28 | if (completed) { emit.complete() } 29 | } 30 | 31 | const subscriptions = [ 32 | s.subscribe({ 33 | ...emit, 34 | next (a) { 35 | f = a 36 | tryNext() 37 | }, 38 | complete: null 39 | }), 40 | ...ts.flatMap((t, i) => 41 | t.subscribe({ 42 | ...emit, 43 | next (a) { 44 | buffer[i] = a 45 | nextMask |= 1 << i 46 | tryNext() 47 | }, 48 | complete () { 49 | completeMask |= 1 << i 50 | tryComplete() 51 | } 52 | }) 53 | ) 54 | ] 55 | 56 | return () => subscriptions.forEach(s => s.unsubscribe()) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/apply.test.js: -------------------------------------------------------------------------------- 1 | import apply from './apply' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s, t, u 5 | let next, error, complete 6 | let emit 7 | 8 | describe('apply', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | u = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('applies the latest function to the latest signal values', () => { 22 | const f = jest.fn((a, b) => b + a) 23 | const g = jest.fn((a, b) => b - a) 24 | 25 | apply(s, [t, u])(emit) 26 | 27 | s.next(f) 28 | t.next(1) 29 | expect(f).not.toHaveBeenCalled() 30 | expect(next).not.toHaveBeenCalled() 31 | u.next(2) 32 | expect(f).toHaveBeenLastCalledWith(1, 2) 33 | expect(next).toHaveBeenCalledTimes(1) 34 | expect(next).toHaveBeenLastCalledWith(3) 35 | s.next(g) 36 | expect(g).toHaveBeenLastCalledWith(1, 2) 37 | expect(next).toHaveBeenCalledTimes(2) 38 | expect(next).toHaveBeenLastCalledWith(1) 39 | t.next(3) 40 | expect(g).toHaveBeenLastCalledWith(3, 2) 41 | expect(next).toHaveBeenCalledTimes(3) 42 | expect(next).toHaveBeenLastCalledWith(-1) 43 | u.next(4) 44 | expect(g).toHaveBeenLastCalledWith(3, 4) 45 | expect(next).toHaveBeenCalledTimes(4) 46 | expect(next).toHaveBeenLastCalledWith(1) 47 | }) 48 | 49 | it('emits an error when any of the given signals emit an error', () => { 50 | apply(s, [t])(emit) 51 | 52 | expect(error).not.toHaveBeenCalled() 53 | s.error('foo') 54 | expect(error).toHaveBeenCalledTimes(1) 55 | expect(error).toHaveBeenLastCalledWith('foo') 56 | t.error('bar') 57 | expect(error).toHaveBeenCalledTimes(2) 58 | expect(error).toHaveBeenLastCalledWith('bar') 59 | }) 60 | 61 | it('completes when all of the target signals are completed', () => { 62 | apply(s, [t])(emit) 63 | 64 | s.complete() 65 | expect(complete).not.toHaveBeenCalled() 66 | t.complete() 67 | expect(complete).toHaveBeenCalledTimes(1) 68 | }) 69 | 70 | it('unmounts the control signal when the unsubscribe function is called', () => { 71 | const unsubscribe = apply(s, [t])(emit) 72 | 73 | expect(s.unmount).not.toHaveBeenCalled() 74 | unsubscribe() 75 | expect(s.unmount).toHaveBeenCalledTimes(1) 76 | }) 77 | 78 | it('unmounts the target signal when the unsubscribe function is called', () => { 79 | const unsubscribe = apply(s, [t])(emit) 80 | 81 | expect(t.unmount).not.toHaveBeenCalled() 82 | unsubscribe() 83 | expect(t.unmount).toHaveBeenCalledTimes(1) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/buffer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Buffers values emitted by the signal `s` and emits the buffer contents when 3 | * it is full. The buffer contents will be emitted when the signal completes, 4 | * regardless of whether the buffer is full. 5 | * 6 | * @private 7 | */ 8 | export default function buffer (n = Infinity, s) { 9 | return emit => { 10 | const buffer = [] 11 | 12 | const flush = () => { 13 | const as = buffer.splice(0, n) 14 | if (as.length > 0) { emit.next(as) } 15 | } 16 | 17 | const subscription = s.subscribe({ 18 | ...emit, 19 | next (a) { 20 | buffer.push(a) 21 | if (buffer.length === n) { flush() } 22 | }, 23 | complete () { 24 | flush() 25 | emit.complete() 26 | } 27 | }) 28 | 29 | return () => subscription.unsubscribe() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/buffer.test.js: -------------------------------------------------------------------------------- 1 | import buffer from './buffer' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s 5 | let next, error, complete 6 | let emit 7 | 8 | describe('buffer', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | 12 | next = jest.fn() 13 | error = jest.fn() 14 | complete = jest.fn() 15 | 16 | emit = { next, error, complete } 17 | }) 18 | 19 | it('buffers values emitted by the target signal', () => { 20 | buffer(2, s)(emit) 21 | 22 | s.next(1) 23 | expect(next).not.toHaveBeenCalled() 24 | s.next(2) 25 | expect(next).toHaveBeenCalledTimes(1) 26 | expect(next).toHaveBeenLastCalledWith([1, 2]) 27 | s.next(3) 28 | expect(next).toHaveBeenCalledTimes(1) 29 | s.next(4) 30 | expect(next).toHaveBeenCalledTimes(2) 31 | expect(next).toHaveBeenLastCalledWith([3, 4]) 32 | }) 33 | 34 | it('emits an error when the given signal emits an error', () => { 35 | buffer(0, s)(emit) 36 | 37 | expect(error).not.toHaveBeenCalled() 38 | s.error('foo') 39 | expect(error).toHaveBeenCalledTimes(1) 40 | expect(error).toHaveBeenCalledWith('foo') 41 | }) 42 | 43 | it('emits the buffer contents when the given signal is completed', () => { 44 | buffer(Infinity, s)(emit) 45 | 46 | s.next(1) 47 | s.next(2) 48 | s.next(3) 49 | expect(next).not.toHaveBeenCalled() 50 | s.complete() 51 | expect(next).toHaveBeenCalledTimes(1) 52 | expect(next).toHaveBeenLastCalledWith([1, 2, 3]) 53 | }) 54 | 55 | it('completes when the given signal is completed', () => { 56 | buffer(0, s)(emit) 57 | 58 | expect(complete).not.toHaveBeenCalled() 59 | s.complete() 60 | expect(complete).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | it('unmounts the given signal when the unsubscribe function is called', () => { 64 | const unsubscribe = buffer(0, s)(emit) 65 | 66 | expect(s.unmount).not.toHaveBeenCalled() 67 | unsubscribe() 68 | expect(s.unmount).toHaveBeenCalledTimes(1) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/bufferWith.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Buffers values emitted by the target signal `t` and emits the buffer contents 3 | * whenever the control signal `s` emits a value. The buffer contents will be 4 | * emitted when the signal completes, regardless of whether the buffer is full. 5 | * 6 | * @private 7 | */ 8 | export default function bufferWith (s, t) { 9 | return emit => { 10 | const buffer = [] 11 | 12 | const flush = () => { 13 | const as = buffer.splice(0) 14 | if (as.length > 0) { emit.next(as) } 15 | } 16 | 17 | const subscriptions = [ 18 | s.subscribe(flush), 19 | t.subscribe({ 20 | ...emit, 21 | next (a) { buffer.push(a) }, 22 | complete () { 23 | flush() 24 | emit.complete() 25 | } 26 | }) 27 | ] 28 | 29 | return () => subscriptions.forEach(s => s.unsubscribe()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/bufferWith.test.js: -------------------------------------------------------------------------------- 1 | import bufferWith from './bufferWith' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s, t 5 | let next, error, complete 6 | let emit 7 | 8 | describe('bufferWith', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | 13 | next = jest.fn() 14 | error = jest.fn() 15 | complete = jest.fn() 16 | 17 | emit = { next, error, complete } 18 | }) 19 | 20 | it('buffers values emitted by the target signal', () => { 21 | bufferWith(s, t)(emit) 22 | 23 | t.next(1) 24 | expect(next).not.toHaveBeenCalled() 25 | t.next(2) 26 | s.next() 27 | expect(next).toHaveBeenCalledTimes(1) 28 | expect(next).toHaveBeenLastCalledWith([1, 2]) 29 | t.next(3) 30 | expect(next).toHaveBeenCalledTimes(1) 31 | t.next(4) 32 | s.next() 33 | expect(next).toHaveBeenCalledTimes(2) 34 | expect(next).toHaveBeenLastCalledWith([3, 4]) 35 | }) 36 | 37 | it('emits an error when the given signal emits an error', () => { 38 | bufferWith(s, t)(emit) 39 | 40 | expect(error).not.toHaveBeenCalled() 41 | t.error('foo') 42 | expect(error).toHaveBeenCalledTimes(1) 43 | expect(error).toHaveBeenCalledWith('foo') 44 | }) 45 | 46 | it('emits the buffer contents when the given signal is completed', () => { 47 | bufferWith(s, t)(emit) 48 | 49 | t.next(1) 50 | t.next(2) 51 | t.next(3) 52 | expect(next).not.toHaveBeenCalled() 53 | t.complete() 54 | expect(next).toHaveBeenCalledTimes(1) 55 | expect(next).toHaveBeenLastCalledWith([1, 2, 3]) 56 | }) 57 | 58 | it('completes when the given signal is completed', () => { 59 | bufferWith(s, t)(emit) 60 | 61 | expect(complete).not.toHaveBeenCalled() 62 | t.complete() 63 | expect(complete).toHaveBeenCalledTimes(1) 64 | }) 65 | 66 | it('unmounts the given signal when the unsubscribe function is called', () => { 67 | const unsubscribe = bufferWith(s, t)(emit) 68 | 69 | expect(s.unmount).not.toHaveBeenCalled() 70 | expect(t.unmount).not.toHaveBeenCalled() 71 | unsubscribe() 72 | expect(s.unmount).toHaveBeenCalledTimes(1) 73 | expect(t.unmount).toHaveBeenCalledTimes(1) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/catchError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies a function `f`, that returns a `Signal`, to the first error emitted 3 | * by the signal `s`. The returned signal will emit values from the signal 4 | * returned by the function. 5 | * 6 | * @private 7 | */ 8 | export default function catchError (f, s) { 9 | return emit => { 10 | let outerSubscription 11 | let innerSubscription 12 | 13 | outerSubscription = s.subscribe({ 14 | ...emit, 15 | error (e) { 16 | outerSubscription.unsubscribe() 17 | outerSubscription = null 18 | const a = f(e) 19 | if (!(a && a.subscribe instanceof Function)) { throw new Error('Value must be a signal') } 20 | innerSubscription = a.subscribe({ ...emit }) 21 | } 22 | }) 23 | 24 | return () => { 25 | if (innerSubscription) { innerSubscription.unsubscribe() } 26 | if (outerSubscription) { outerSubscription.unsubscribe() } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/catchError.test.js: -------------------------------------------------------------------------------- 1 | import { always, id } from 'fkit' 2 | 3 | import catchError from './catchError' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s, t, u 7 | let next, error, complete 8 | let emit 9 | 10 | describe('catchError', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | t = mockSignal() 14 | u = mockSignal() 15 | 16 | next = jest.fn() 17 | error = jest.fn() 18 | complete = jest.fn() 19 | 20 | emit = { next, error, complete } 21 | }) 22 | 23 | it('applies a function to the signal errors', () => { 24 | catchError(id, s)(emit) 25 | 26 | s.error(t) 27 | expect(error).not.toHaveBeenCalled() 28 | t.next(1) 29 | expect(next).toHaveBeenCalledTimes(1) 30 | expect(next).toHaveBeenLastCalledWith(1) 31 | t.next(2) 32 | expect(next).toHaveBeenCalledTimes(2) 33 | expect(next).toHaveBeenLastCalledWith(2) 34 | s.error(u) 35 | expect(error).not.toHaveBeenCalled() 36 | t.next(3) 37 | expect(next).toHaveBeenCalledTimes(3) 38 | expect(next).toHaveBeenLastCalledWith(3) 39 | }) 40 | 41 | it('throws an error when the given signal emits a non-signal error', () => { 42 | catchError(id, s)(emit) 43 | 44 | expect(() => s.error('foo')).toThrow('Value must be a signal') 45 | }) 46 | 47 | it('completes when the given signal is completed', () => { 48 | catchError(always(), s)(emit) 49 | 50 | expect(complete).not.toHaveBeenCalled() 51 | s.complete() 52 | expect(complete).toHaveBeenCalledTimes(1) 53 | }) 54 | 55 | it('unmounts the given signal when it emits an error', () => { 56 | catchError(id, s)(emit) 57 | 58 | expect(s.unmount).not.toHaveBeenCalled() 59 | s.error(t) 60 | expect(s.unmount).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | it('unmounts the outer signal when the unsubscribe function is called', () => { 64 | const unsubscribe = catchError(id, s)(emit) 65 | 66 | expect(s.unmount).not.toHaveBeenCalled() 67 | unsubscribe() 68 | expect(s.unmount).toHaveBeenCalledTimes(1) 69 | }) 70 | 71 | it('unmounts the inner signal when the unsubscribe function is called', () => { 72 | const t = mockSignal() 73 | const unsubscribe = catchError(id, s)(emit) 74 | 75 | s.error(t) 76 | expect(t.unmount).not.toHaveBeenCalled() 77 | unsubscribe() 78 | expect(t.unmount).toHaveBeenCalledTimes(1) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/concat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Concatenates the signals `ss` and emits their values. The returned signal 3 | * will join the given signals, waiting for each one to complete before joining 4 | * the next, and will complete once *all* of the given signals have completed. 5 | * 6 | * @private 7 | */ 8 | export default function concat (ss) { 9 | return emit => { 10 | let subscription 11 | 12 | const innerComplete = () => { 13 | if (subscription) { 14 | subscription.unsubscribe() 15 | subscription = null 16 | } 17 | subscribeNext() 18 | } 19 | 20 | // Subscribes to the next signal in the queue 21 | const subscribeNext = () => { 22 | if (ss.length > 0) { 23 | const a = ss.shift() 24 | if (a !== undefined) { 25 | subscription = a.subscribe({ ...emit, complete: innerComplete }) 26 | } 27 | } else { 28 | emit.complete() 29 | } 30 | } 31 | 32 | subscribeNext() 33 | 34 | return () => { 35 | if (subscription) { subscription.unsubscribe() } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/concat.test.js: -------------------------------------------------------------------------------- 1 | import concat from './concat' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s, t 5 | let next, error, complete 6 | let emit 7 | 8 | describe('concat', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | 13 | next = jest.fn() 14 | error = jest.fn() 15 | complete = jest.fn() 16 | 17 | emit = { next, error, complete } 18 | }) 19 | 20 | it('emits a value when the current signal emits a value', () => { 21 | concat([s, t])(emit) 22 | 23 | expect(next).not.toHaveBeenCalled() 24 | s.next(1) 25 | s.next(2) 26 | s.complete() 27 | t.next(3) 28 | s.next(4) 29 | t.complete() 30 | t.next(5) 31 | expect(next).toHaveBeenCalledTimes(3) 32 | expect(next).toHaveBeenNthCalledWith(1, 1) 33 | expect(next).toHaveBeenNthCalledWith(2, 2) 34 | expect(next).toHaveBeenNthCalledWith(3, 3) 35 | }) 36 | 37 | it('emits an error when the current signal emit an error', () => { 38 | concat([s, t])(emit) 39 | 40 | expect(error).not.toHaveBeenCalled() 41 | s.error('foo') 42 | expect(error).toHaveBeenCalledTimes(1) 43 | expect(error).toHaveBeenLastCalledWith('foo') 44 | s.complete() 45 | t.error('bar') 46 | expect(error).toHaveBeenCalledTimes(2) 47 | expect(error).toHaveBeenLastCalledWith('bar') 48 | }) 49 | 50 | it('completes when all of the given signals are completed', () => { 51 | concat([s, t])(emit) 52 | 53 | s.complete() 54 | expect(complete).not.toHaveBeenCalled() 55 | t.complete() 56 | expect(complete).toHaveBeenCalledTimes(1) 57 | }) 58 | 59 | it('unmounts the given signals when the unsubscribe function is called', () => { 60 | const unsubscribe = concat([s, t])(emit) 61 | 62 | expect(s.unmount).not.toHaveBeenCalled() 63 | s.complete() 64 | expect(s.unmount).toHaveBeenCalledTimes(1) 65 | expect(t.unmount).not.toHaveBeenCalled() 66 | unsubscribe() 67 | expect(t.unmount).toHaveBeenCalledTimes(1) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/concatMap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies a function `f`, that returns a `Signal`, to each value emitted by 3 | * the signal `s`. The returned signal will join all signals returned by the 4 | * function, waiting for each one to complete before merging the next. 5 | * 6 | * @private 7 | */ 8 | export default function concatMap (f, s) { 9 | return emit => { 10 | const queue = [] 11 | let innerSubscription 12 | let innerCompleted = false 13 | let outerCompleted = false 14 | 15 | // Emits a complete event if all signals have completed 16 | const tryComplete = () => { 17 | if ((!innerSubscription || innerCompleted) && outerCompleted) { 18 | emit.complete() 19 | } 20 | } 21 | 22 | const innerComplete = () => { 23 | innerCompleted = true 24 | if (innerSubscription) { 25 | innerSubscription.unsubscribe() 26 | innerSubscription = null 27 | } 28 | subscribeNext() 29 | tryComplete() 30 | } 31 | 32 | const outerComplete = () => { 33 | outerCompleted = true 34 | tryComplete() 35 | } 36 | 37 | // Subscribes to the next signal in the queue 38 | const subscribeNext = () => { 39 | if (queue.length > 0) { 40 | const a = queue.shift() 41 | if (a !== undefined) { 42 | const b = f(a) 43 | if (!(b && b.subscribe instanceof Function)) { throw new Error('Value must be a signal') } 44 | innerCompleted = false 45 | innerSubscription = b.subscribe({ 46 | ...emit, 47 | complete: innerComplete 48 | }) 49 | } 50 | } 51 | } 52 | 53 | // Enqueues the given signal 54 | const enqueueSignal = a => { 55 | queue.push(a) 56 | if (!innerSubscription) { subscribeNext() } 57 | } 58 | 59 | const outerSubscription = s.subscribe({ 60 | ...emit, 61 | next: enqueueSignal, 62 | complete: outerComplete 63 | }) 64 | 65 | return () => { 66 | if (innerSubscription) { innerSubscription.unsubscribe() } 67 | outerSubscription.unsubscribe() 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/concatMap.test.js: -------------------------------------------------------------------------------- 1 | import { id } from 'fkit' 2 | 3 | import concatMap from './concatMap' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s, t, u 7 | let next, error, complete 8 | let emit 9 | 10 | describe('concatMap', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | t = mockSignal() 14 | u = mockSignal() 15 | 16 | next = jest.fn() 17 | error = jest.fn() 18 | complete = jest.fn() 19 | 20 | emit = { next, error, complete } 21 | }) 22 | 23 | it('applies a function to the signal values', () => { 24 | concatMap(id, s)(emit) 25 | 26 | s.next(t) 27 | expect(next).not.toHaveBeenCalled() 28 | t.next(1) 29 | expect(next).toHaveBeenCalledTimes(1) 30 | expect(next).toHaveBeenLastCalledWith(1) 31 | s.next(u) 32 | t.next(2) 33 | expect(next).toHaveBeenCalledTimes(2) 34 | expect(next).toHaveBeenLastCalledWith(2) 35 | t.complete() 36 | u.next(3) 37 | expect(next).toHaveBeenCalledTimes(3) 38 | expect(next).toHaveBeenLastCalledWith(3) 39 | u.next(4) 40 | expect(next).toHaveBeenCalledTimes(4) 41 | expect(next).toHaveBeenLastCalledWith(4) 42 | }) 43 | 44 | it('throws an error when the given signal emits a non-signal value', () => { 45 | concatMap(id, s)(emit) 46 | 47 | expect(() => s.next('foo')).toThrow('Value must be a signal') 48 | }) 49 | 50 | it('emits an error when the given signal emits an error', () => { 51 | concatMap(id, s)(emit) 52 | 53 | expect(error).not.toHaveBeenCalled() 54 | s.error('foo') 55 | expect(error).toHaveBeenCalledTimes(1) 56 | expect(error).toHaveBeenCalledWith('foo') 57 | }) 58 | 59 | it('completes when the given signal is completed', () => { 60 | concatMap(id, s)(emit) 61 | 62 | expect(complete).not.toHaveBeenCalled() 63 | s.complete() 64 | expect(complete).toHaveBeenCalledTimes(1) 65 | }) 66 | 67 | it('unmounts the outer signal when the unsubscribe function is called', () => { 68 | const unsubscribe = concatMap(id, s)(emit) 69 | 70 | expect(s.unmount).not.toHaveBeenCalled() 71 | unsubscribe() 72 | expect(s.unmount).toHaveBeenCalledTimes(1) 73 | }) 74 | 75 | it('unmounts the inner signal when the unsubscribe function is called', () => { 76 | const unsubscribe = concatMap(id, s)(emit) 77 | 78 | s.next(t) 79 | expect(t.unmount).not.toHaveBeenCalled() 80 | unsubscribe() 81 | expect(t.unmount).toHaveBeenCalledTimes(1) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/cycle.js: -------------------------------------------------------------------------------- 1 | import stateMachine from './stateMachine' 2 | 3 | /** 4 | * Cycles through the values of an array `as` for every value emitted by the 5 | * signal `s`. 6 | * 7 | * @private 8 | */ 9 | export default function cycle (as, s) { 10 | return stateMachine((a, b, emit) => { 11 | emit.next(as[a]) 12 | return (a + 1) % as.length 13 | }, 0, s) 14 | } 15 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/cycle.test.js: -------------------------------------------------------------------------------- 1 | import { range } from 'fkit' 2 | 3 | import cycle from './cycle' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('cycle', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('cycles through the values of an array', () => { 22 | const t = cycle(range(1, 3), s) 23 | 24 | t(emit) 25 | 26 | range(0, 6).forEach(n => { 27 | s.next() 28 | expect(next).toHaveBeenNthCalledWith(n + 1, (n % 3) + 1) 29 | }) 30 | }) 31 | 32 | it('emits an error when the given signal emits an error', () => { 33 | cycle([], s)(emit) 34 | 35 | expect(error).not.toHaveBeenCalled() 36 | s.error('foo') 37 | expect(error).toHaveBeenCalledTimes(1) 38 | expect(error).toHaveBeenCalledWith('foo') 39 | }) 40 | 41 | it('completes when the given signal is completed', () => { 42 | cycle([], s)(emit) 43 | 44 | expect(complete).not.toHaveBeenCalled() 45 | s.complete() 46 | expect(complete).toHaveBeenCalledTimes(1) 47 | }) 48 | 49 | it('unmounts the given signal when the unsubscribe function is called', () => { 50 | const unsubscribe = cycle([], s)(emit) 51 | 52 | expect(s.unmount).not.toHaveBeenCalled() 53 | unsubscribe() 54 | expect(s.unmount).toHaveBeenCalledTimes(1) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/debounce.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Waits until `n` milliseconds after the last burst of values before emitting 3 | * the most recent value from the signal `s`. 4 | * 5 | * @private 6 | */ 7 | export default function debounce (n, s) { 8 | return emit => { 9 | let buffer 10 | let id 11 | 12 | const flush = () => { 13 | if (buffer) { emit.next(buffer) } 14 | buffer = null 15 | } 16 | 17 | const subscription = s.subscribe({ 18 | ...emit, 19 | next (a) { 20 | clearTimeout(id) 21 | buffer = a 22 | id = setTimeout(flush, n) 23 | }, 24 | complete () { 25 | clearTimeout(id) 26 | flush() 27 | emit.complete() 28 | } 29 | }) 30 | 31 | return () => { 32 | clearTimeout(id) 33 | subscription.unsubscribe() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/debounce.test.js: -------------------------------------------------------------------------------- 1 | import debounce from './debounce' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s 5 | let next, error, complete 6 | let emit 7 | 8 | describe('debounce', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | 12 | next = jest.fn() 13 | error = jest.fn() 14 | complete = jest.fn() 15 | 16 | emit = { next, error, complete } 17 | 18 | jest.useFakeTimers() 19 | }) 20 | 21 | afterEach(() => { 22 | jest.useRealTimers() 23 | }) 24 | 25 | it('debounces the signal values', () => { 26 | debounce(1000, s)(emit) 27 | 28 | s.next(1) 29 | s.next(2) 30 | s.next(3) 31 | expect(next).not.toHaveBeenCalled() 32 | jest.advanceTimersByTime(1000) 33 | expect(next).toHaveBeenCalledTimes(1) 34 | expect(next).toHaveBeenCalledWith(3) 35 | }) 36 | 37 | it('emits an error when the given signal emits an error', () => { 38 | debounce(1000, s)(emit) 39 | 40 | expect(error).not.toHaveBeenCalled() 41 | s.error('foo') 42 | expect(error).toHaveBeenCalledTimes(1) 43 | expect(error).toHaveBeenCalledWith('foo') 44 | }) 45 | 46 | it('completes when the given signal is completed', () => { 47 | debounce(1000, s)(emit) 48 | 49 | expect(complete).not.toHaveBeenCalled() 50 | s.complete() 51 | expect(complete).toHaveBeenCalledTimes(1) 52 | }) 53 | 54 | it('unmounts the given signal when the unsubscribe function is called', () => { 55 | const unsubscribe = debounce(1000, s)(emit) 56 | 57 | expect(s.unmount).not.toHaveBeenCalled() 58 | unsubscribe() 59 | expect(s.unmount).toHaveBeenCalledTimes(1) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/dedupeWith.js: -------------------------------------------------------------------------------- 1 | import stateMachine from './stateMachine' 2 | 3 | /** 4 | * Removes duplicate values emitted by the signal `s` using a comparator 5 | * function `f`. 6 | * 7 | * @private 8 | */ 9 | export default function dedupeWith (f, s) { 10 | return stateMachine((a, b, emit) => { 11 | if (!f(a, b)) { emit.next(b) } 12 | return b 13 | }, null, s) 14 | } 15 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/dedupeWith.test.js: -------------------------------------------------------------------------------- 1 | import { eq } from 'fkit' 2 | 3 | import dedupeWith from './dedupeWith' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('dedupeWith', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('removes duplicate signal values with a comparator function', () => { 22 | dedupeWith(eq, s)(emit) 23 | 24 | expect(next).not.toHaveBeenCalled() 25 | s.next('foo') 26 | s.next('foo') 27 | expect(next).toHaveBeenCalledTimes(1) 28 | expect(next).toHaveBeenLastCalledWith('foo') 29 | s.next('bar') 30 | expect(next).toHaveBeenCalledTimes(2) 31 | expect(next).toHaveBeenLastCalledWith('bar') 32 | }) 33 | 34 | it('emits an error when the given signal emits an error', () => { 35 | dedupeWith(eq, s)(emit) 36 | 37 | expect(error).not.toHaveBeenCalled() 38 | s.error('foo') 39 | expect(error).toHaveBeenCalledTimes(1) 40 | expect(error).toHaveBeenCalledWith('foo') 41 | }) 42 | 43 | it('completes when the given signal is completed', () => { 44 | dedupeWith(eq, s)(emit) 45 | 46 | expect(complete).not.toHaveBeenCalled() 47 | s.complete() 48 | expect(complete).toHaveBeenCalledTimes(1) 49 | }) 50 | 51 | it('unmounts the given signal when the unsubscribe function is called', () => { 52 | const unsubscribe = dedupeWith(eq, s)(emit) 53 | 54 | expect(s.unmount).not.toHaveBeenCalled() 55 | unsubscribe() 56 | expect(s.unmount).toHaveBeenCalledTimes(1) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/delay.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Delays each value emitted by the signal `s` for `n` milliseconds. 3 | * 4 | * @private 5 | */ 6 | export default function delay (n, s) { 7 | return emit => { 8 | let id 9 | 10 | const subscription = s.subscribe({ 11 | ...emit, 12 | next (a) { id = setTimeout(() => emit.next(a), n) } 13 | }) 14 | 15 | return () => { 16 | clearTimeout(id) 17 | subscription.unsubscribe() 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/delay.test.js: -------------------------------------------------------------------------------- 1 | import delay from './delay' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s 5 | let next, error, complete 6 | let emit 7 | 8 | describe('delay', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | 12 | next = jest.fn() 13 | error = jest.fn() 14 | complete = jest.fn() 15 | 16 | emit = { next, error, complete } 17 | 18 | jest.useFakeTimers() 19 | }) 20 | 21 | afterEach(() => { 22 | jest.useRealTimers() 23 | }) 24 | 25 | it('delays the signal values', () => { 26 | delay(1000, s)(emit) 27 | 28 | s.next(1) 29 | s.next(2) 30 | jest.advanceTimersByTime(500) 31 | s.next(3) 32 | expect(next).not.toHaveBeenCalled() 33 | jest.advanceTimersByTime(500) 34 | expect(next).toHaveBeenCalledTimes(2) 35 | expect(next).toHaveBeenNthCalledWith(1, 1) 36 | expect(next).toHaveBeenNthCalledWith(2, 2) 37 | jest.advanceTimersByTime(500) 38 | expect(next).toHaveBeenCalledTimes(3) 39 | expect(next).toHaveBeenNthCalledWith(3, 3) 40 | }) 41 | 42 | it('emits an error when the given signal emits an error', () => { 43 | delay(1000, s)(emit) 44 | 45 | expect(error).not.toHaveBeenCalled() 46 | s.error('foo') 47 | expect(error).toHaveBeenCalledTimes(1) 48 | expect(error).toHaveBeenCalledWith('foo') 49 | }) 50 | 51 | it('completes when the given signal is completed', () => { 52 | delay(1000, s)(emit) 53 | 54 | expect(complete).not.toHaveBeenCalled() 55 | s.complete() 56 | expect(complete).toHaveBeenCalledTimes(1) 57 | }) 58 | 59 | it('unmounts the given signal when the unsubscribe function is called', () => { 60 | const unsubscribe = delay(1000, s)(emit) 61 | 62 | expect(s.unmount).not.toHaveBeenCalled() 63 | unsubscribe() 64 | expect(s.unmount).toHaveBeenCalledTimes(1) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/drop.js: -------------------------------------------------------------------------------- 1 | import stateMachine from './stateMachine' 2 | 3 | /** 4 | * Drops the first `n` values emitted by the signal `s`. 5 | * 6 | * @private 7 | */ 8 | export default function drop (n, s) { 9 | return stateMachine((a, b, emit) => { 10 | if (a >= n) { emit.next(b) } 11 | return a + 1 12 | }, 0, s) 13 | } 14 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/drop.test.js: -------------------------------------------------------------------------------- 1 | import drop from './drop' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s 5 | let next, error, complete 6 | let emit 7 | 8 | describe('drop', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | 12 | next = jest.fn() 13 | error = jest.fn() 14 | complete = jest.fn() 15 | 16 | emit = { next, error, complete } 17 | }) 18 | 19 | it('drops the given number of values', () => { 20 | drop(1, s)(emit) 21 | 22 | s.next(1) 23 | expect(next).not.toHaveBeenCalled() 24 | s.next(2) 25 | expect(next).toHaveBeenCalledTimes(1) 26 | expect(next).toHaveBeenLastCalledWith(2) 27 | s.next(3) 28 | expect(next).toHaveBeenCalledTimes(2) 29 | expect(next).toHaveBeenLastCalledWith(3) 30 | }) 31 | 32 | it('emits an error when the given signal emits an error', () => { 33 | drop(1, s)(emit) 34 | 35 | expect(error).not.toHaveBeenCalled() 36 | s.error('foo') 37 | expect(error).toHaveBeenCalledTimes(1) 38 | expect(error).toHaveBeenCalledWith('foo') 39 | }) 40 | 41 | it('completes when the given signal is completed', () => { 42 | drop(1, s)(emit) 43 | 44 | expect(complete).not.toHaveBeenCalled() 45 | s.complete() 46 | expect(complete).toHaveBeenCalledTimes(1) 47 | }) 48 | 49 | it('unmounts the given signal when the unsubscribe function is called', () => { 50 | const unsubscribe = drop(1, s)(emit) 51 | 52 | expect(s.unmount).not.toHaveBeenCalled() 53 | unsubscribe() 54 | expect(s.unmount).toHaveBeenCalledTimes(1) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/dropUntil.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Drops values emitted by the target signal `t` until the control signal `s` 3 | * emits a value. 4 | * 5 | * @private 6 | */ 7 | export default function dropUntil (s, t) { 8 | return emit => { 9 | let enabled = false 10 | 11 | const subscriptions = [ 12 | t.subscribe({ 13 | ...emit, 14 | next (a) { if (enabled) { emit.next(a) } } 15 | }), 16 | s.subscribe({ 17 | ...emit, 18 | next () { enabled = true } 19 | }) 20 | ] 21 | 22 | return () => subscriptions.forEach(s => s.unsubscribe()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/dropUntil.test.js: -------------------------------------------------------------------------------- 1 | import mockSignal from '../internal/mockSignal' 2 | import dropUntil from './dropUntil' 3 | 4 | let s, t 5 | let next, error, complete 6 | let emit 7 | 8 | describe('dropUntil', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | 13 | next = jest.fn() 14 | error = jest.fn() 15 | complete = jest.fn() 16 | 17 | emit = { next, error, complete } 18 | }) 19 | 20 | it('drops values from the target signal until the control signal emits a value', () => { 21 | dropUntil(s, t)(emit) 22 | 23 | t.next(1) 24 | expect(next).not.toHaveBeenCalled() 25 | s.next() 26 | t.next(2) 27 | expect(next).toHaveBeenCalledTimes(1) 28 | expect(next).toHaveBeenLastCalledWith(2) 29 | }) 30 | 31 | it('emits an error when either signal emits an error', () => { 32 | dropUntil(s, t)(emit) 33 | 34 | expect(error).not.toHaveBeenCalled() 35 | s.error('foo') 36 | expect(error).toHaveBeenCalledTimes(1) 37 | expect(error).toHaveBeenLastCalledWith('foo') 38 | t.error('bar') 39 | expect(error).toHaveBeenCalledTimes(2) 40 | expect(error).toHaveBeenLastCalledWith('bar') 41 | }) 42 | 43 | it('completes when the target signal is completed', () => { 44 | dropUntil(s, t)(emit) 45 | 46 | expect(complete).not.toHaveBeenCalled() 47 | t.complete() 48 | expect(complete).toHaveBeenCalledTimes(1) 49 | }) 50 | 51 | it('completes when the control signal is completed', () => { 52 | dropUntil(s, t)(emit) 53 | 54 | expect(complete).not.toHaveBeenCalled() 55 | s.complete() 56 | expect(complete).toHaveBeenCalledTimes(1) 57 | }) 58 | 59 | it('unmounts the control signal when the unsubscribe function is called', () => { 60 | const unsubscribe = dropUntil(s, t)(emit) 61 | 62 | expect(s.unmount).not.toHaveBeenCalled() 63 | unsubscribe() 64 | expect(s.unmount).toHaveBeenCalledTimes(1) 65 | }) 66 | 67 | it('unmounts the target signal when the unsubscribe function is called', () => { 68 | const unsubscribe = dropUntil(s, t)(emit) 69 | 70 | expect(t.unmount).not.toHaveBeenCalled() 71 | unsubscribe() 72 | expect(t.unmount).toHaveBeenCalledTimes(1) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/dropWhile.js: -------------------------------------------------------------------------------- 1 | import stateMachine from './stateMachine' 2 | 3 | /** 4 | * Drops values emitted by the signal `s` while the predicate function `p` is 5 | * satisfied. The returned signal will emit values once the predicate function 6 | * is not satisfied. 7 | * 8 | * @private 9 | */ 10 | export default function dropWhile (p, s) { 11 | return stateMachine((a, b, emit) => { 12 | if (a || !p(b)) { 13 | emit.next(b) 14 | a = true 15 | } 16 | return a 17 | }, false, s) 18 | } 19 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/dropWhile.test.js: -------------------------------------------------------------------------------- 1 | import { id, lt } from 'fkit' 2 | 3 | import dropWhile from './dropWhile' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('dropWhile', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('drops values from the target signal until the predicate is false', () => { 22 | dropWhile(lt(2), s)(emit) 23 | 24 | s.next(1) 25 | expect(next).not.toHaveBeenCalled() 26 | s.next(2) 27 | expect(next).toHaveBeenCalledTimes(1) 28 | expect(next).toHaveBeenLastCalledWith(2) 29 | s.next(3) 30 | expect(next).toHaveBeenCalledTimes(2) 31 | expect(next).toHaveBeenLastCalledWith(3) 32 | }) 33 | 34 | it('emits an error when the given signal emits an error', () => { 35 | dropWhile(id, s)(emit) 36 | 37 | expect(error).not.toHaveBeenCalled() 38 | s.error('foo') 39 | expect(error).toHaveBeenCalledTimes(1) 40 | expect(error).toHaveBeenCalledWith('foo') 41 | }) 42 | 43 | it('completes when the given signal is completed', () => { 44 | dropWhile(id, s)(emit) 45 | 46 | expect(complete).not.toHaveBeenCalled() 47 | s.complete() 48 | expect(complete).toHaveBeenCalledTimes(1) 49 | }) 50 | 51 | it('unmounts the given signal when the unsubscribe function is called', () => { 52 | const unsubscribe = dropWhile(id, s)(emit) 53 | 54 | expect(s.unmount).not.toHaveBeenCalled() 55 | unsubscribe() 56 | expect(s.unmount).toHaveBeenCalledTimes(1) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/filter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Filters the signal `s` by only emitting values that satisfy a predicate 3 | * function `p`. 4 | * 5 | * @private 6 | */ 7 | export default function filter (p, s) { 8 | return emit => { 9 | let index = 0 10 | 11 | const subscription = s.subscribe({ 12 | ...emit, 13 | next (a) { if (p(a, index++)) { emit.next(a) } } 14 | }) 15 | 16 | return () => subscription.unsubscribe() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/filter.test.js: -------------------------------------------------------------------------------- 1 | import { id, gt } from 'fkit' 2 | 3 | import filter from './filter' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('filter', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('emits values from the given signal that satisfy the predicate function', () => { 22 | const f = jest.fn(gt(1)) 23 | 24 | filter(f, s)(emit) 25 | 26 | s.next(1) 27 | expect(f).toHaveBeenLastCalledWith(1, 0) 28 | expect(next).not.toHaveBeenCalled() 29 | s.next(2) 30 | expect(f).toHaveBeenLastCalledWith(2, 1) 31 | expect(next).toHaveBeenCalledTimes(1) 32 | expect(next).toHaveBeenLastCalledWith(2) 33 | s.next(3) 34 | expect(f).toHaveBeenLastCalledWith(3, 2) 35 | expect(next).toHaveBeenCalledTimes(2) 36 | expect(next).toHaveBeenLastCalledWith(3) 37 | }) 38 | 39 | it('emits an error when the given signal emits an error', () => { 40 | filter(id, s)(emit) 41 | 42 | expect(error).not.toHaveBeenCalled() 43 | s.error('foo') 44 | expect(error).toHaveBeenCalledTimes(1) 45 | expect(error).toHaveBeenCalledWith('foo') 46 | }) 47 | 48 | it('completes when the given signal is completed', () => { 49 | filter(id, s)(emit) 50 | 51 | expect(complete).not.toHaveBeenCalled() 52 | s.complete() 53 | expect(complete).toHaveBeenCalledTimes(1) 54 | }) 55 | 56 | it('unmounts the given signal when the unsubscribe function is called', () => { 57 | const unsubscribe = filter(id, s)(emit) 58 | 59 | expect(s.unmount).not.toHaveBeenCalled() 60 | unsubscribe() 61 | expect(s.unmount).toHaveBeenCalledTimes(1) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/fold.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies a reducer function `f` to each value emitted by the signal `s`. The 3 | * accumulated value will be emitted when the signal has completed. 4 | * 5 | * @private 6 | */ 7 | export default function fold (f, a, s) { 8 | return emit => { 9 | let index = 0 10 | 11 | const subscription = s.subscribe({ 12 | ...emit, 13 | next (b) { a = f(a, b, index++) }, 14 | complete () { 15 | // Emit the final value. 16 | emit.next(a) 17 | emit.complete() 18 | } 19 | }) 20 | 21 | return () => subscription.unsubscribe() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/fold.test.js: -------------------------------------------------------------------------------- 1 | import { add, id } from 'fkit' 2 | 3 | import fold from './fold' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('fold', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('folds a function over the signal values', () => { 22 | const f = jest.fn(add) 23 | 24 | fold(f, 0, s)(emit) 25 | 26 | s.next(1) 27 | expect(f).toHaveBeenLastCalledWith(0, 1, 0) 28 | s.next(2) 29 | expect(f).toHaveBeenLastCalledWith(1, 2, 1) 30 | s.next(3) 31 | expect(f).toHaveBeenLastCalledWith(3, 3, 2) 32 | expect(next).not.toHaveBeenCalled() 33 | s.complete(1) 34 | expect(next).toHaveBeenCalledTimes(1) 35 | expect(next).toHaveBeenCalledWith(6) 36 | }) 37 | 38 | it('emits an error when the given signal emits an error', () => { 39 | fold(id, 0, s)(emit) 40 | 41 | expect(error).not.toHaveBeenCalled() 42 | s.error('foo') 43 | expect(error).toHaveBeenCalledTimes(1) 44 | expect(error).toHaveBeenCalledWith('foo') 45 | }) 46 | 47 | it('completes when the given signal is completed', () => { 48 | fold(id, 0, s)(emit) 49 | 50 | expect(complete).not.toHaveBeenCalled() 51 | s.complete() 52 | expect(complete).toHaveBeenCalledTimes(1) 53 | }) 54 | 55 | it('unmounts the given signal when the unsubscribe function is called', () => { 56 | const unsubscribe = fold(id, 0, s)(emit) 57 | 58 | expect(s.unmount).not.toHaveBeenCalled() 59 | unsubscribe() 60 | expect(s.unmount).toHaveBeenCalledTimes(1) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/hold.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Stops emitting values from the target signal `t` while the control signal 3 | * `s` is truthy. 4 | * 5 | * @private 6 | */ 7 | export default function hold (s, t) { 8 | return emit => { 9 | let enabled = true 10 | 11 | const subscriptions = [ 12 | s.subscribe({ 13 | ...emit, 14 | next (a) { enabled = !a } 15 | }), 16 | t.subscribe({ 17 | ...emit, 18 | next (a) { 19 | if (enabled) { emit.next(a) } 20 | } 21 | }) 22 | ] 23 | 24 | return () => subscriptions.forEach(s => s.unsubscribe()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/hold.test.js: -------------------------------------------------------------------------------- 1 | import hold from './hold' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s, t 5 | let next, error, complete 6 | let emit 7 | 8 | describe('hold', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | 13 | next = jest.fn() 14 | error = jest.fn() 15 | complete = jest.fn() 16 | 17 | emit = { next, error, complete } 18 | }) 19 | 20 | it('stops emitting values from the target signal while the control signal is truthy', () => { 21 | hold(s, t)(emit) 22 | 23 | expect(next).not.toHaveBeenCalled() 24 | s.next(false) 25 | t.next('foo') 26 | expect(next).toHaveBeenCalledTimes(1) 27 | expect(next).toHaveBeenLastCalledWith('foo') 28 | s.next(true) 29 | t.next('bar') 30 | expect(next).toHaveBeenCalledTimes(1) 31 | expect(next).toHaveBeenLastCalledWith('foo') 32 | }) 33 | 34 | it('emits an error when either signal emits an error', () => { 35 | hold(s, t)(emit) 36 | 37 | expect(error).not.toHaveBeenCalled() 38 | s.error('foo') 39 | expect(error).toHaveBeenCalledTimes(1) 40 | expect(error).toHaveBeenLastCalledWith('foo') 41 | t.error('bar') 42 | expect(error).toHaveBeenCalledTimes(2) 43 | expect(error).toHaveBeenLastCalledWith('bar') 44 | }) 45 | 46 | it('completes when the target signal is completed', () => { 47 | hold(s, t)(emit) 48 | 49 | expect(complete).not.toHaveBeenCalled() 50 | t.complete() 51 | expect(complete).toHaveBeenCalledTimes(1) 52 | }) 53 | 54 | it('completes when the control signal is completed', () => { 55 | hold(s, t)(emit) 56 | 57 | expect(complete).not.toHaveBeenCalled() 58 | s.complete() 59 | expect(complete).toHaveBeenCalledTimes(1) 60 | }) 61 | 62 | it('unmounts the control signal when the unsubscribe function is called', () => { 63 | const unsubscribe = hold(s, t)(emit) 64 | 65 | expect(s.unmount).not.toHaveBeenCalled() 66 | unsubscribe() 67 | expect(s.unmount).toHaveBeenCalledTimes(1) 68 | }) 69 | 70 | it('unmounts the target signal when the unsubscribe function is called', () => { 71 | const unsubscribe = hold(s, t)(emit) 72 | 73 | expect(t.unmount).not.toHaveBeenCalled() 74 | unsubscribe() 75 | expect(t.unmount).toHaveBeenCalledTimes(1) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/map.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies a function `f` to each value emitted by the signal `s`. 3 | * 4 | * @private 5 | */ 6 | export default function map (f, s) { 7 | return emit => { 8 | let index = 0 9 | 10 | const subscription = s.subscribe({ 11 | ...emit, 12 | next (a) { emit.next(f(a, index++)) } 13 | }) 14 | 15 | return () => subscription.unsubscribe() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/map.test.js: -------------------------------------------------------------------------------- 1 | import { id, inc } from 'fkit' 2 | 3 | import map from './map' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('map', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('maps a function over the signal values', () => { 22 | const f = jest.fn(inc) 23 | 24 | map(f, s)(emit) 25 | 26 | expect(next).not.toHaveBeenCalled() 27 | s.next(1) 28 | expect(f).toHaveBeenLastCalledWith(1, 0) 29 | expect(next).toHaveBeenCalledTimes(1) 30 | expect(next).toHaveBeenLastCalledWith(2) 31 | s.next(2) 32 | expect(f).toHaveBeenLastCalledWith(2, 1) 33 | expect(next).toHaveBeenCalledTimes(2) 34 | expect(next).toHaveBeenLastCalledWith(3) 35 | s.next(3) 36 | expect(f).toHaveBeenLastCalledWith(3, 2) 37 | expect(next).toHaveBeenCalledTimes(3) 38 | expect(next).toHaveBeenLastCalledWith(4) 39 | }) 40 | 41 | it('emits an error when the given signal emits an error', () => { 42 | map(id, s)(emit) 43 | 44 | expect(error).not.toHaveBeenCalled() 45 | s.error('foo') 46 | expect(error).toHaveBeenCalledTimes(1) 47 | expect(error).toHaveBeenCalledWith('foo') 48 | }) 49 | 50 | it('completes when the given signal is completed', () => { 51 | map(id, s)(emit) 52 | 53 | expect(complete).not.toHaveBeenCalled() 54 | s.complete() 55 | expect(complete).toHaveBeenCalledTimes(1) 56 | }) 57 | 58 | it('unmounts the given signal when the unsubscribe function is called', () => { 59 | const unsubscribe = map(id, s)(emit) 60 | 61 | expect(s.unmount).not.toHaveBeenCalled() 62 | unsubscribe() 63 | expect(s.unmount).toHaveBeenCalledTimes(1) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/merge.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Merges the signals `ss` and emits their values. The returned signal will 3 | * complete once *all* of the given signals have completed. 4 | * 5 | * @private 6 | */ 7 | export default function merge (ss) { 8 | return emit => { 9 | let n = 0 10 | 11 | const subscriptions = ss.map(s => s.subscribe({ 12 | ...emit, 13 | complete () { if (++n >= ss.length) { emit.complete() } } 14 | })) 15 | 16 | return () => subscriptions.forEach(s => s.unsubscribe()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/merge.test.js: -------------------------------------------------------------------------------- 1 | import merge from './merge' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s, t 5 | let next, error, complete 6 | let emit 7 | 8 | describe('merge', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | 13 | next = jest.fn() 14 | error = jest.fn() 15 | complete = jest.fn() 16 | 17 | emit = { next, error, complete } 18 | }) 19 | 20 | it('merges the given signals', () => { 21 | merge([s, t])(emit) 22 | 23 | expect(next).not.toHaveBeenCalled() 24 | s.next('foo') 25 | expect(next).toHaveBeenCalledTimes(1) 26 | expect(next).toHaveBeenLastCalledWith('foo') 27 | t.next('bar') 28 | expect(next).toHaveBeenCalledTimes(2) 29 | expect(next).toHaveBeenLastCalledWith('bar') 30 | }) 31 | 32 | it('emits an error when any of the given signals emit an error', () => { 33 | merge([s, t])(emit) 34 | 35 | expect(error).not.toHaveBeenCalled() 36 | s.error('foo') 37 | expect(error).toHaveBeenCalledTimes(1) 38 | expect(error).toHaveBeenLastCalledWith('foo') 39 | t.error('bar') 40 | expect(error).toHaveBeenCalledTimes(2) 41 | expect(error).toHaveBeenLastCalledWith('bar') 42 | }) 43 | 44 | it('completes when all of the given signals are completed', () => { 45 | merge([s, t])(emit) 46 | 47 | s.complete() 48 | expect(complete).not.toHaveBeenCalled() 49 | t.complete() 50 | expect(complete).toHaveBeenCalledTimes(1) 51 | }) 52 | 53 | it('unmounts the given signals when the unsubscribe function is called', () => { 54 | const unsubscribe = merge([s, t])(emit) 55 | 56 | expect(s.unmount).not.toHaveBeenCalled() 57 | expect(t.unmount).not.toHaveBeenCalled() 58 | unsubscribe() 59 | expect(s.unmount).toHaveBeenCalledTimes(1) 60 | expect(t.unmount).toHaveBeenCalledTimes(1) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/sample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Emits the most recent value from the target signal `t` whenever the control 3 | * signal `s` emits a value. 4 | * 5 | * @private 6 | */ 7 | export default function sample (s, t) { 8 | return emit => { 9 | let buffer 10 | 11 | const subscriptions = [ 12 | t.subscribe({ ...emit, next (a) { buffer = a } }), 13 | s.subscribe({ 14 | ...emit, 15 | next (a) { if (buffer !== undefined) { emit.next(buffer) } } 16 | }) 17 | ] 18 | 19 | return () => subscriptions.forEach(s => s.unsubscribe()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/sample.test.js: -------------------------------------------------------------------------------- 1 | import mockSignal from '../internal/mockSignal' 2 | import sample from './sample' 3 | 4 | let s, t 5 | let next, error, complete 6 | let emit 7 | 8 | describe('sample', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | 13 | next = jest.fn() 14 | error = jest.fn() 15 | complete = jest.fn() 16 | 17 | emit = { next, error, complete } 18 | }) 19 | 20 | it('emits values from the target signal whenever the control signal emits a value', () => { 21 | sample(s, t)(emit) 22 | 23 | t.next('foo') 24 | expect(next).not.toHaveBeenCalled() 25 | s.next() 26 | expect(next).toHaveBeenCalledTimes(1) 27 | expect(next).toHaveBeenLastCalledWith('foo') 28 | t.next('bar') 29 | expect(next).toHaveBeenCalledTimes(1) 30 | s.next() 31 | expect(next).toHaveBeenCalledTimes(2) 32 | expect(next).toHaveBeenLastCalledWith('bar') 33 | }) 34 | 35 | it('emits an error when either signal emits an error', () => { 36 | sample(s, t)(emit) 37 | 38 | expect(error).not.toHaveBeenCalled() 39 | s.error('foo') 40 | expect(error).toHaveBeenCalledTimes(1) 41 | expect(error).toHaveBeenLastCalledWith('foo') 42 | t.error('bar') 43 | expect(error).toHaveBeenCalledTimes(2) 44 | expect(error).toHaveBeenLastCalledWith('bar') 45 | }) 46 | 47 | it('completes when the target signal is completed', () => { 48 | sample(s, t)(emit) 49 | 50 | expect(complete).not.toHaveBeenCalled() 51 | t.complete() 52 | expect(complete).toHaveBeenCalledTimes(1) 53 | }) 54 | 55 | it('completes when the control signal is completed', () => { 56 | sample(s, t)(emit) 57 | 58 | expect(complete).not.toHaveBeenCalled() 59 | s.complete() 60 | expect(complete).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | it('unmounts the control signal when the unsubscribe function is called', () => { 64 | const unsubscribe = sample(s, t)(emit) 65 | 66 | expect(s.unmount).not.toHaveBeenCalled() 67 | unsubscribe() 68 | expect(s.unmount).toHaveBeenCalledTimes(1) 69 | }) 70 | 71 | it('unmounts the target signal when the unsubscribe function is called', () => { 72 | const unsubscribe = sample(s, t)(emit) 73 | 74 | expect(t.unmount).not.toHaveBeenCalled() 75 | unsubscribe() 76 | expect(t.unmount).toHaveBeenCalledTimes(1) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/scan.js: -------------------------------------------------------------------------------- 1 | import { asap } from '../scheduler' 2 | 3 | /** 4 | * Applies a reducer function `f` to each value emitted by the signal `s`. The 5 | * accumulated value will be emitted for each value emitted by the signal. 6 | * 7 | * @private 8 | */ 9 | export default function scan (f, a, s) { 10 | return emit => { 11 | let index = 0 12 | 13 | // Emit the starting value. 14 | asap(() => { emit.next(a) }) 15 | 16 | const subscription = s.subscribe({ 17 | ...emit, 18 | next (b) { 19 | a = f(a, b, index++) 20 | emit.next(a) 21 | } 22 | }) 23 | 24 | return () => subscription.unsubscribe() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/scan.test.js: -------------------------------------------------------------------------------- 1 | import { add, always } from 'fkit' 2 | 3 | import mockSignal from '../internal/mockSignal' 4 | import scan from './scan' 5 | import { asap } from '../scheduler' 6 | 7 | jest.mock('../scheduler') 8 | 9 | let s 10 | let next, error, complete 11 | let emit 12 | 13 | describe('scan', () => { 14 | beforeEach(() => { 15 | s = mockSignal() 16 | 17 | next = jest.fn() 18 | error = jest.fn() 19 | complete = jest.fn() 20 | 21 | emit = { next, error, complete } 22 | 23 | asap.mockImplementation(f => f()) 24 | }) 25 | 26 | afterEach(() => { 27 | asap.mockRestore() 28 | }) 29 | 30 | it('scans a function over the signal values', () => { 31 | const f = jest.fn(add) 32 | 33 | scan(f, 0, s)(emit) 34 | 35 | expect(next).toHaveBeenCalledTimes(1) 36 | expect(next).toHaveBeenLastCalledWith(0) 37 | s.next(1) 38 | expect(f).toHaveBeenLastCalledWith(0, 1, 0) 39 | expect(next).toHaveBeenCalledTimes(2) 40 | expect(next).toHaveBeenLastCalledWith(1) 41 | s.next(2) 42 | expect(f).toHaveBeenLastCalledWith(1, 2, 1) 43 | expect(next).toHaveBeenCalledTimes(3) 44 | expect(next).toHaveBeenLastCalledWith(3) 45 | s.next(3) 46 | expect(f).toHaveBeenLastCalledWith(3, 3, 2) 47 | expect(next).toHaveBeenCalledTimes(4) 48 | expect(next).toHaveBeenLastCalledWith(6) 49 | }) 50 | 51 | it('emits an error when the given signal emits an error', () => { 52 | scan(always(), 0, s)(emit) 53 | 54 | expect(error).not.toHaveBeenCalled() 55 | s.error('foo') 56 | expect(error).toHaveBeenCalledTimes(1) 57 | expect(error).toHaveBeenCalledWith('foo') 58 | }) 59 | 60 | it('completes when the given signal is completed', () => { 61 | scan(always(), 0, s)(emit) 62 | 63 | expect(complete).not.toHaveBeenCalled() 64 | s.complete() 65 | expect(complete).toHaveBeenCalledTimes(1) 66 | }) 67 | 68 | it('unmounts the given signal when the unsubscribe function is called', () => { 69 | const unsubscribe = scan(always(), 0, s)(emit) 70 | 71 | expect(s.unmount).not.toHaveBeenCalled() 72 | unsubscribe() 73 | expect(s.unmount).toHaveBeenCalledTimes(1) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/sequential.js: -------------------------------------------------------------------------------- 1 | import stateMachine from './stateMachine' 2 | 3 | /** 4 | * Emits the next value from an array `as` for every value emitted by the 5 | * signal `s`. The returned signal will complete immediately after the last 6 | * value has been emitted. 7 | * 8 | * @private 9 | */ 10 | export default function sequential (as, s) { 11 | return stateMachine((a, b, emit) => { 12 | emit.next(as[a]) 13 | if (a === as.length - 1) { emit.complete() } 14 | return a + 1 15 | }, 0, s) 16 | } 17 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/sequential.test.js: -------------------------------------------------------------------------------- 1 | import { range } from 'fkit' 2 | 3 | import mockSignal from '../internal/mockSignal' 4 | import sequential from './sequential' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('sequential', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('sequentially emits the values of an array', () => { 22 | const t = sequential(range(1, 3), s) 23 | 24 | t(emit) 25 | 26 | range(1, 3).forEach(n => { 27 | s.next() 28 | expect(next).toHaveBeenNthCalledWith(n, n) 29 | }) 30 | }) 31 | 32 | it('emits an error when the given signal emits an error', () => { 33 | sequential([], s)(emit) 34 | 35 | expect(error).not.toHaveBeenCalled() 36 | s.error('foo') 37 | expect(error).toHaveBeenCalledTimes(1) 38 | expect(error).toHaveBeenCalledWith('foo') 39 | }) 40 | 41 | it('completes when the given signal is completed', () => { 42 | sequential([], s)(emit) 43 | 44 | expect(complete).not.toHaveBeenCalled() 45 | s.complete() 46 | expect(complete).toHaveBeenCalledTimes(1) 47 | }) 48 | 49 | it('unmounts the given signal when the unsubscribe function is called', () => { 50 | const unsubscribe = sequential([], s)(emit) 51 | 52 | expect(s.unmount).not.toHaveBeenCalled() 53 | unsubscribe() 54 | expect(s.unmount).toHaveBeenCalledTimes(1) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/stateMachine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies a transform function `f` to each value emitted by the signal `s`. 3 | * 4 | * @private 5 | */ 6 | export default function stateMachine (f, a, s) { 7 | return emit => { 8 | const subscription = s.subscribe({ 9 | ...emit, 10 | next (b) { a = f(a, b, emit) } 11 | }) 12 | 13 | return () => subscription.unsubscribe() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/stateMachine.test.js: -------------------------------------------------------------------------------- 1 | import { always } from 'fkit' 2 | 3 | import mockSignal from '../internal/mockSignal' 4 | import stateMachine from './stateMachine' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('stateMachine', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('folds a transform function over the signal values', () => { 22 | stateMachine((a, b, emit) => { 23 | emit.next(a * b) 24 | return a + b 25 | }, 0, s)(emit) 26 | 27 | expect(next).not.toHaveBeenCalled() 28 | s.next(1) 29 | expect(next).toHaveBeenCalledTimes(1) 30 | expect(next).toHaveBeenLastCalledWith(0) 31 | s.next(2) 32 | expect(next).toHaveBeenCalledTimes(2) 33 | expect(next).toHaveBeenLastCalledWith(2) 34 | s.next(3) 35 | expect(next).toHaveBeenCalledTimes(3) 36 | expect(next).toHaveBeenLastCalledWith(9) 37 | }) 38 | 39 | it('emits an error when the given signal emits an error', () => { 40 | stateMachine(always(), 0, s)(emit) 41 | 42 | expect(error).not.toHaveBeenCalled() 43 | s.error('foo') 44 | expect(error).toHaveBeenCalledTimes(1) 45 | expect(error).toHaveBeenCalledWith('foo') 46 | }) 47 | 48 | it('completes when the given signal is completed', () => { 49 | stateMachine(always(), 0, s)(emit) 50 | 51 | expect(complete).not.toHaveBeenCalled() 52 | s.complete() 53 | expect(complete).toHaveBeenCalledTimes(1) 54 | }) 55 | 56 | it('unmounts the given signal when the unsubscribe function is called', () => { 57 | const unsubscribe = stateMachine(always(), 0, s)(emit) 58 | 59 | expect(s.unmount).not.toHaveBeenCalled() 60 | unsubscribe() 61 | expect(s.unmount).toHaveBeenCalledTimes(1) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/switchMap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies a function `f` that returns a `Signal` to each value emitted by the 3 | * signal `s`. The returned signal will emit values from the most recent signal 4 | * returned by the function. 5 | * 6 | * @private 7 | */ 8 | export default function switchMap (f, s) { 9 | return emit => { 10 | let innerSubscription 11 | 12 | const outerSubscription = s.subscribe({ 13 | ...emit, 14 | next (a) { 15 | const b = f(a) 16 | if (!(b && b.subscribe instanceof Function)) { throw new Error('Value must be a signal') } 17 | if (innerSubscription) { innerSubscription.unsubscribe() } 18 | innerSubscription = b.subscribe({ ...emit, complete: undefined }) 19 | } 20 | }) 21 | 22 | return () => { 23 | if (innerSubscription) { innerSubscription.unsubscribe() } 24 | outerSubscription.unsubscribe() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/switchMap.test.js: -------------------------------------------------------------------------------- 1 | import { always, id } from 'fkit' 2 | 3 | import mockSignal from '../internal/mockSignal' 4 | import switchMap from './switchMap' 5 | 6 | let s, t, u 7 | let next, error, complete 8 | let emit 9 | 10 | describe('switchMap', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | t = mockSignal() 14 | u = mockSignal() 15 | 16 | next = jest.fn() 17 | error = jest.fn() 18 | complete = jest.fn() 19 | 20 | emit = { next, error, complete } 21 | }) 22 | 23 | it('applies a function to the signal values', () => { 24 | switchMap(id, s)(emit) 25 | s.next(t) 26 | expect(next).not.toHaveBeenCalled() 27 | t.next(1) 28 | expect(next).toHaveBeenCalledTimes(1) 29 | expect(next).toHaveBeenLastCalledWith(1) 30 | s.next(u) 31 | t.next(2) 32 | u.next(3) 33 | expect(next).toHaveBeenCalledTimes(2) 34 | expect(next).toHaveBeenLastCalledWith(3) 35 | u.next(4) 36 | expect(next).toHaveBeenCalledTimes(3) 37 | expect(next).toHaveBeenLastCalledWith(4) 38 | }) 39 | 40 | it('throws an error when the given signal emits a non-signal value', () => { 41 | switchMap(id, s)(emit) 42 | expect(() => s.next('foo')).toThrow('Value must be a signal') 43 | }) 44 | 45 | it('emits an error when the given signal emits an error', () => { 46 | switchMap(always(), s)(emit) 47 | expect(error).not.toHaveBeenCalled() 48 | s.error('foo') 49 | expect(error).toHaveBeenCalledTimes(1) 50 | expect(error).toHaveBeenCalledWith('foo') 51 | }) 52 | 53 | it('completes when the given signal is completed', () => { 54 | switchMap(id, s)(emit) 55 | expect(complete).not.toHaveBeenCalled() 56 | s.complete() 57 | expect(complete).toHaveBeenCalledTimes(1) 58 | }) 59 | 60 | it('unmounts the outer signal when the unsubscribe function is called', () => { 61 | const unsubscribe = switchMap(id, s)(emit) 62 | 63 | expect(s.unmount).not.toHaveBeenCalled() 64 | unsubscribe() 65 | expect(s.unmount).toHaveBeenCalledTimes(1) 66 | }) 67 | 68 | it('unmounts the inner signal when the unsubscribe function is called', () => { 69 | const t = mockSignal(emit) 70 | const unsubscribe = switchMap(id, s)() 71 | 72 | s.next(t) 73 | expect(t.unmount).not.toHaveBeenCalled() 74 | unsubscribe() 75 | expect(t.unmount).toHaveBeenCalledTimes(1) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/take.js: -------------------------------------------------------------------------------- 1 | import stateMachine from './stateMachine' 2 | 3 | /** 4 | * Takes the first `n` values emitted by the signal `s`, and then completes. 5 | * 6 | * @private 7 | */ 8 | export default function take (n, s) { 9 | return stateMachine(([enabled, counter], a, emit) => { 10 | if (enabled) { 11 | emit.next(a) 12 | if (counter === n - 1) { 13 | emit.complete() 14 | enabled = false 15 | } 16 | } 17 | return [enabled, counter + 1] 18 | }, [true, 0], s) 19 | } 20 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/take.test.js: -------------------------------------------------------------------------------- 1 | import mockSignal from '../internal/mockSignal' 2 | import take from './take' 3 | 4 | let s 5 | let next, error, complete 6 | let emit 7 | 8 | describe('take', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | 12 | next = jest.fn() 13 | error = jest.fn() 14 | complete = jest.fn() 15 | 16 | emit = { next, error, complete } 17 | }) 18 | 19 | it('takes the given number of values', () => { 20 | take(2, s)(emit) 21 | 22 | expect(next).not.toHaveBeenCalled() 23 | s.next(1) 24 | s.next(2) 25 | s.next(3) 26 | s.next(4) 27 | expect(next).toHaveBeenCalledTimes(2) 28 | expect(next).toHaveBeenNthCalledWith(1, 1) 29 | expect(next).toHaveBeenNthCalledWith(2, 2) 30 | expect(complete).toHaveBeenCalledTimes(1) 31 | }) 32 | 33 | it('emits an error when the given signal emits an error', () => { 34 | take(1, s)(emit) 35 | 36 | expect(error).not.toHaveBeenCalled() 37 | s.error('foo') 38 | expect(error).toHaveBeenCalledTimes(1) 39 | expect(error).toHaveBeenCalledWith('foo') 40 | }) 41 | 42 | it('completes after the given number of values', () => { 43 | take(2, s)(emit) 44 | 45 | s.next() 46 | expect(complete).not.toHaveBeenCalled() 47 | s.next() 48 | s.next() 49 | expect(complete).toHaveBeenCalledTimes(1) 50 | }) 51 | 52 | it('completes when the given signal is completed', () => { 53 | take(1, s)(emit) 54 | 55 | expect(complete).not.toHaveBeenCalled() 56 | s.complete() 57 | expect(complete).toHaveBeenCalledTimes(1) 58 | }) 59 | 60 | it('unmounts the given signal when the unsubscribe function is called', () => { 61 | const unsubscribe = take(1, s)(emit) 62 | 63 | expect(s.unmount).not.toHaveBeenCalled() 64 | unsubscribe() 65 | expect(s.unmount).toHaveBeenCalledTimes(1) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/takeUntil.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Emits values from the target signal `t` until the control signal `s` emits a 3 | * value. The returned signal will complete once the control signal emits a 4 | * value. 5 | * 6 | * @private 7 | */ 8 | export default function takeUntil (s, t) { 9 | return emit => { 10 | const subscriptions = [ 11 | t.subscribe(emit), 12 | s.subscribe({ ...emit, next (a) { emit.complete() } }) 13 | ] 14 | 15 | return () => subscriptions.forEach(s => s.unsubscribe()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/takeUntil.test.js: -------------------------------------------------------------------------------- 1 | import mockSignal from '../internal/mockSignal' 2 | import takeUntil from './takeUntil' 3 | 4 | let s, t 5 | let next, error, complete 6 | let emit 7 | 8 | describe('takeUntil', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | t = mockSignal() 12 | 13 | next = jest.fn() 14 | error = jest.fn() 15 | complete = jest.fn() 16 | 17 | emit = { next, error, complete } 18 | }) 19 | 20 | it('emits values from the target signal until the control signal emits a value', () => { 21 | takeUntil(s, t)(emit) 22 | 23 | expect(next).not.toHaveBeenCalled() 24 | t.next(1) 25 | expect(next).toHaveBeenCalledTimes(1) 26 | expect(next).toHaveBeenLastCalledWith(1) 27 | s.next() 28 | expect(complete).toHaveBeenCalledTimes(1) 29 | }) 30 | 31 | it('emits an error when either signal emits an error', () => { 32 | takeUntil(s, t)(emit) 33 | 34 | expect(error).not.toHaveBeenCalled() 35 | s.error('foo') 36 | expect(error).toHaveBeenCalledTimes(1) 37 | expect(error).toHaveBeenLastCalledWith('foo') 38 | t.error('bar') 39 | expect(error).toHaveBeenCalledTimes(2) 40 | expect(error).toHaveBeenLastCalledWith('bar') 41 | }) 42 | 43 | it('completes when the target signal is completed', () => { 44 | takeUntil(s, t)(emit) 45 | 46 | expect(complete).not.toHaveBeenCalled() 47 | t.complete() 48 | expect(complete).toHaveBeenCalledTimes(1) 49 | }) 50 | 51 | it('completes when the control signal is completed', () => { 52 | takeUntil(s, t)(emit) 53 | 54 | expect(complete).not.toHaveBeenCalled() 55 | s.complete() 56 | expect(complete).toHaveBeenCalledTimes(1) 57 | }) 58 | 59 | it('unmounts the control signal when the unsubscribe function is called', () => { 60 | const unsubscribe = takeUntil(s, t)(emit) 61 | 62 | expect(s.unmount).not.toHaveBeenCalled() 63 | unsubscribe() 64 | expect(s.unmount).toHaveBeenCalledTimes(1) 65 | }) 66 | 67 | it('unmounts the target signal when the unsubscribe function is called', () => { 68 | const unsubscribe = takeUntil(s, t)(emit) 69 | 70 | expect(t.unmount).not.toHaveBeenCalled() 71 | unsubscribe() 72 | expect(t.unmount).toHaveBeenCalledTimes(1) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/takeWhile.js: -------------------------------------------------------------------------------- 1 | import stateMachine from './stateMachine' 2 | 3 | /** 4 | * Emits values from the signal `s` while the predicate function `p` is 5 | * satisfied. The returned signal will complete once the predicate function is 6 | * not satisfied. 7 | * 8 | * @private 9 | */ 10 | export default function takeWhile (p, s) { 11 | return stateMachine((enabled, a, emit) => { 12 | if (enabled) { 13 | if (p(a)) { 14 | emit.next(a) 15 | } else { 16 | emit.complete() 17 | enabled = false 18 | } 19 | } 20 | return enabled 21 | }, true, s) 22 | } 23 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/takeWhile.test.js: -------------------------------------------------------------------------------- 1 | import { always, lt } from 'fkit' 2 | 3 | import mockSignal from '../internal/mockSignal' 4 | import takeWhile from './takeWhile' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('takeWhile', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('emits values from the target signal while the predicate is true', () => { 22 | takeWhile(lt(3), s)(emit) 23 | 24 | expect(next).not.toHaveBeenCalled() 25 | s.next(1) 26 | s.next(2) 27 | s.next(3) 28 | s.next(4) 29 | expect(next).toHaveBeenCalledTimes(2) 30 | expect(next).toHaveBeenNthCalledWith(1, 1) 31 | expect(next).toHaveBeenNthCalledWith(2, 2) 32 | }) 33 | 34 | it('emits an error when the given signal emits an error', () => { 35 | takeWhile(always(), s)(emit) 36 | 37 | expect(error).not.toHaveBeenCalled() 38 | s.error('foo') 39 | expect(error).toHaveBeenCalledTimes(1) 40 | expect(error).toHaveBeenCalledWith('foo') 41 | }) 42 | 43 | it('completes when the predicate is false', () => { 44 | takeWhile(lt(3), s)(emit) 45 | 46 | s.next(1) 47 | s.next(2) 48 | expect(complete).not.toHaveBeenCalled() 49 | s.next(3) 50 | expect(complete).toHaveBeenCalledTimes(1) 51 | }) 52 | 53 | it('completes when the given signal is completed', () => { 54 | takeWhile(always(), s)(emit) 55 | 56 | expect(complete).not.toHaveBeenCalled() 57 | s.complete() 58 | expect(complete).toHaveBeenCalledTimes(1) 59 | }) 60 | 61 | it('unmounts the given signal when the unsubscribe function is called', () => { 62 | const unsubscribe = takeWhile(always(), s)(emit) 63 | 64 | expect(s.unmount).not.toHaveBeenCalled() 65 | unsubscribe() 66 | expect(s.unmount).toHaveBeenCalledTimes(1) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/tap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Performs the side effect function `f` for each value emitted by the signal 3 | * `s`. 4 | * 5 | * @private 6 | */ 7 | export default function tap (f, s) { 8 | return emit => { 9 | const subscription = s.subscribe({ 10 | ...emit, 11 | next (a) { 12 | f(a) 13 | emit.next(a) 14 | } 15 | }) 16 | 17 | return () => subscription.unsubscribe() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/tap.test.js: -------------------------------------------------------------------------------- 1 | import { id } from 'fkit' 2 | 3 | import tap from './tap' 4 | import mockSignal from '../internal/mockSignal' 5 | 6 | let s 7 | let next, error, complete 8 | let emit 9 | 10 | describe('tap', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | 14 | next = jest.fn() 15 | error = jest.fn() 16 | complete = jest.fn() 17 | 18 | emit = { next, error, complete } 19 | }) 20 | 21 | it('performs a side effect', () => { 22 | const f = jest.fn() 23 | 24 | tap(f, s)(emit) 25 | 26 | expect(next).not.toHaveBeenCalled() 27 | s.next(1) 28 | expect(f).toHaveBeenLastCalledWith(1) 29 | expect(next).toHaveBeenCalledTimes(1) 30 | expect(next).toHaveBeenLastCalledWith(1) 31 | }) 32 | 33 | it('emits an error when the given signal emits an error', () => { 34 | tap(id, s)(emit) 35 | 36 | expect(error).not.toHaveBeenCalled() 37 | s.error('foo') 38 | expect(error).toHaveBeenCalledTimes(1) 39 | expect(error).toHaveBeenCalledWith('foo') 40 | }) 41 | 42 | it('completes when the given signal is completed', () => { 43 | tap(id, s)(emit) 44 | 45 | expect(complete).not.toHaveBeenCalled() 46 | s.complete() 47 | expect(complete).toHaveBeenCalledTimes(1) 48 | }) 49 | 50 | it('unmounts the given signal when the unsubscribe function is called', () => { 51 | const unsubscribe = tap(id, s)(emit) 52 | 53 | expect(s.unmount).not.toHaveBeenCalled() 54 | unsubscribe() 55 | expect(s.unmount).toHaveBeenCalledTimes(1) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/throttle.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Limits the rate at which values are emitted by the signal `s`. Values are 3 | * dropped when the rate limit is exceeded. 4 | * 5 | * @private 6 | */ 7 | export default function throttle (n, s) { 8 | return emit => { 9 | let lastTime = null 10 | 11 | const subscription = s.subscribe({ 12 | ...emit, 13 | next (a) { 14 | const t = Date.now() 15 | if (lastTime === null || t - lastTime >= n) { 16 | emit.next(a) 17 | lastTime = t 18 | } 19 | } 20 | }) 21 | 22 | return () => subscription.unsubscribe() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/throttle.test.js: -------------------------------------------------------------------------------- 1 | import mockSignal from '../internal/mockSignal' 2 | import throttle from './throttle' 3 | 4 | let s 5 | let next, error, complete 6 | let emit 7 | 8 | describe('throttle', () => { 9 | beforeEach(() => { 10 | s = mockSignal() 11 | 12 | next = jest.fn() 13 | error = jest.fn() 14 | complete = jest.fn() 15 | 16 | emit = { next, error, complete } 17 | }) 18 | 19 | it('throttle the signal values', () => { 20 | throttle(1000, s)(emit) 21 | 22 | expect(next).not.toHaveBeenCalled() 23 | Date.now = jest.fn(() => 0) 24 | s.next('foo') 25 | s.next('bar') 26 | expect(next).toHaveBeenCalledTimes(1) 27 | expect(next).toHaveBeenLastCalledWith('foo') 28 | Date.now = jest.fn(() => 1000) 29 | s.next('bar') 30 | expect(next).toHaveBeenCalledTimes(2) 31 | expect(next).toHaveBeenLastCalledWith('bar') 32 | }) 33 | 34 | it('emits an error when the given signal emits an error', () => { 35 | throttle(1000, s)(emit) 36 | 37 | expect(error).not.toHaveBeenCalled() 38 | s.error('foo') 39 | expect(error).toHaveBeenCalledTimes(1) 40 | expect(error).toHaveBeenCalledWith('foo') 41 | }) 42 | 43 | it('completes when the given signal is completed', () => { 44 | throttle(1000, s)(emit) 45 | 46 | expect(complete).not.toHaveBeenCalled() 47 | s.complete() 48 | expect(complete).toHaveBeenCalledTimes(1) 49 | }) 50 | 51 | it('unmounts the given signal when the unsubscribe function is called', () => { 52 | const unsubscribe = throttle(1000, s)(emit) 53 | 54 | expect(s.unmount).not.toHaveBeenCalled() 55 | unsubscribe() 56 | expect(s.unmount).toHaveBeenCalledTimes(1) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/window.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Windows values emitted by the target signal `t` and emits a new signal 3 | * whenever the control signal `s` emits a value. 4 | * 5 | * @private 6 | */ 7 | export default function window (s, t) { 8 | return emit => { 9 | let windowEmit 10 | let innerSubscription 11 | 12 | const closeWindow = () => { 13 | if (innerSubscription) { 14 | windowEmit.complete() 15 | innerSubscription.unsubscribe() 16 | innerSubscription = null 17 | } 18 | } 19 | 20 | const newWindow = () => { 21 | closeWindow() 22 | const w = emit => { 23 | windowEmit = emit 24 | innerSubscription = t.subscribe(emit) 25 | } 26 | emit.next(w) 27 | } 28 | 29 | const subscriptions = [ 30 | s.subscribe(newWindow), 31 | t.subscribe({ 32 | complete () { 33 | closeWindow() 34 | emit.complete() 35 | } 36 | }) 37 | ] 38 | 39 | // Start a new window immediately 40 | newWindow() 41 | 42 | return () => { 43 | closeWindow() 44 | subscriptions.forEach(s => s.unsubscribe()) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/window.test.js: -------------------------------------------------------------------------------- 1 | import window from './window' 2 | import mockSignal from '../internal/mockSignal' 3 | 4 | let s, t 5 | let next, error, complete 6 | let innerNext, innerError, innerComplete 7 | let emit 8 | let innerEmit 9 | 10 | describe('window', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | t = mockSignal() 14 | 15 | next = jest.fn() 16 | error = jest.fn() 17 | complete = jest.fn() 18 | innerNext = jest.fn() 19 | innerError = jest.fn() 20 | innerComplete = jest.fn() 21 | 22 | emit = { next, error, complete } 23 | innerEmit = { next: innerNext, error: innerError, complete: innerComplete } 24 | }) 25 | 26 | it('emits a signal that forwards values from the target signal', () => { 27 | window(s, t)(emit) 28 | 29 | const u = next.mock.calls[0][0] 30 | u(innerEmit) 31 | 32 | expect(innerNext).not.toHaveBeenCalled() 33 | t.next(1) 34 | expect(innerNext).toHaveBeenLastCalledWith(1) 35 | s.next() 36 | }) 37 | 38 | it('emits a signal that forwards errors from the target signal', () => { 39 | window(s, t)(emit) 40 | 41 | const u = next.mock.calls[0][0] 42 | u(innerEmit) 43 | 44 | expect(error).not.toHaveBeenCalled() 45 | expect(innerError).not.toHaveBeenCalled() 46 | t.error('foo') 47 | expect(innerError).toHaveBeenCalledTimes(1) 48 | expect(innerError).toHaveBeenCalledWith('foo') 49 | }) 50 | 51 | it('completes the emitted signal when starting a new window', () => { 52 | window(s, t)(emit) 53 | 54 | const u = next.mock.calls[0][0] 55 | u(innerEmit) 56 | 57 | expect(complete).not.toHaveBeenCalled() 58 | expect(innerComplete).not.toHaveBeenCalled() 59 | s.next() 60 | expect(innerComplete).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | it('completes when the target signal is completed', () => { 64 | window(s, t)(emit) 65 | 66 | const u = next.mock.calls[0][0] 67 | u(innerEmit) 68 | 69 | expect(complete).not.toHaveBeenCalled() 70 | expect(innerComplete).not.toHaveBeenCalled() 71 | t.complete() 72 | expect(complete).toHaveBeenCalledTimes(1) 73 | expect(innerComplete).toHaveBeenCalledTimes(1) 74 | }) 75 | 76 | it('unmounts all signals when the unsubscribe function is called', () => { 77 | const unsubscribe = window(s, t)(emit) 78 | 79 | const u = next.mock.calls[0][0] 80 | u(innerEmit) 81 | 82 | expect(s.unmount).not.toHaveBeenCalled() 83 | expect(t.unmount).not.toHaveBeenCalled() 84 | unsubscribe() 85 | expect(s.unmount).toHaveBeenCalledTimes(1) 86 | expect(t.unmount).toHaveBeenCalledTimes(1) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/zipLatestWith.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies the function `f` to the latest values emitted by the signals `ss`. 3 | * The returned signal will complete when *all* of the given signals have 4 | * completed. 5 | * 6 | * @private 7 | */ 8 | export default function zipLatestWith (f, ss) { 9 | return emit => { 10 | const buffer = new Array(ss.length) 11 | let enabled = false 12 | let completed = false 13 | let nextMask = 0 14 | let completeMask = 0 15 | 16 | // Checks whether all mask bits are set 17 | const checkMask = mask => mask === (1 << ss.length) - 1 18 | 19 | // Emits the next value if all signals are enabled 20 | const tryNext = () => { 21 | enabled ||= checkMask(nextMask) 22 | if (enabled) { emit.next(f(...buffer)) } 23 | } 24 | 25 | // Emits a complete event if all signals are completed 26 | const tryComplete = () => { 27 | completed ||= checkMask(completeMask) 28 | if (completed) { emit.complete() } 29 | } 30 | 31 | const subscriptions = ss.map((rs, i) => 32 | rs.subscribe({ 33 | ...emit, 34 | next (a) { 35 | buffer[i] = a 36 | nextMask |= 1 << i 37 | tryNext() 38 | }, 39 | complete () { 40 | completeMask |= 1 << i 41 | tryComplete() 42 | } 43 | }) 44 | ) 45 | 46 | // Return the unmount function 47 | return () => subscriptions.forEach(s => s.unsubscribe()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/zipLatestWith.test.js: -------------------------------------------------------------------------------- 1 | import { always } from 'fkit' 2 | 3 | import mockSignal from '../internal/mockSignal' 4 | import zipLatestWith from './zipLatestWith' 5 | 6 | let s, t, u 7 | let next, error, complete 8 | let emit 9 | 10 | describe('zipLatestWith', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | t = mockSignal() 14 | u = mockSignal() 15 | 16 | next = jest.fn() 17 | error = jest.fn() 18 | complete = jest.fn() 19 | 20 | emit = { next, error, complete } 21 | }) 22 | 23 | it('zips the latest signal values using a function', () => { 24 | const f = jest.fn((a, b, c) => a + b + c) 25 | 26 | zipLatestWith(f, [s, t, u])(emit) 27 | 28 | s.next(1) 29 | expect(next).not.toHaveBeenCalled() 30 | t.next(2) 31 | expect(next).not.toHaveBeenCalled() 32 | u.next(3) 33 | expect(f).toHaveBeenLastCalledWith(1, 2, 3) 34 | expect(next).toHaveBeenCalledTimes(1) 35 | expect(next).toHaveBeenLastCalledWith(6) 36 | u.next(4) 37 | expect(f).toHaveBeenLastCalledWith(1, 2, 4) 38 | expect(next).toHaveBeenCalledTimes(2) 39 | expect(next).toHaveBeenLastCalledWith(7) 40 | }) 41 | 42 | it('emits an error when any of the given signals emit an error', () => { 43 | zipLatestWith(always(), [s, t])(emit) 44 | 45 | expect(error).not.toHaveBeenCalled() 46 | s.error('foo') 47 | expect(error).toHaveBeenCalledTimes(1) 48 | expect(error).toHaveBeenLastCalledWith('foo') 49 | t.error('bar') 50 | expect(error).toHaveBeenCalledTimes(2) 51 | expect(error).toHaveBeenLastCalledWith('bar') 52 | }) 53 | 54 | it('completes when all of the given signals are completed', () => { 55 | zipLatestWith(always(), [s, t])(emit) 56 | 57 | s.complete() 58 | expect(complete).not.toHaveBeenCalled() 59 | t.complete() 60 | expect(complete).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | it('unmounts the given signals when the unsubscribe function is called', () => { 64 | const unsubscribe = zipLatestWith(always(), [s, t])(emit) 65 | 66 | expect(s.unmount).not.toHaveBeenCalled() 67 | expect(t.unmount).not.toHaveBeenCalled() 68 | unsubscribe() 69 | expect(s.unmount).toHaveBeenCalledTimes(1) 70 | expect(t.unmount).toHaveBeenCalledTimes(1) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/zipWith.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Applies the function `f` to the corresponding values emitted by the signals 3 | * `ss`. The returned signal will complete when *all* of the given signals have 4 | * completed. 5 | * 6 | * @private 7 | */ 8 | export default function zipWith (f, ss) { 9 | return emit => { 10 | const buffers = Array.from({ length: ss.length }, () => []) 11 | let completed = false 12 | let completeMask = 0 13 | 14 | // Checks whether all mask bits are set 15 | const checkMask = mask => mask === (1 << ss.length) - 1 16 | 17 | // Checks each of the signals have at least one buffered value. 18 | const bufferIsFull = () => buffers.every(buffer => buffer.length > 0) 19 | 20 | const tryNext = () => { 21 | if (bufferIsFull()) { 22 | // Get the next buffered value for each of the signals. 23 | const as = buffers.reduce((as, buffer) => { 24 | as.push(buffer.shift()) 25 | return as 26 | }, []) 27 | 28 | // Emit the value. 29 | emit.next(f(...as)) 30 | } 31 | } 32 | 33 | // Emits a complete event if all signals are completed 34 | const tryComplete = () => { 35 | completed ||= checkMask(completeMask) 36 | if (completed) { emit.complete() } 37 | } 38 | 39 | const subscriptions = ss.map((s, i) => 40 | s.subscribe({ 41 | ...emit, 42 | next (a) { 43 | buffers[i].push(a) 44 | tryNext() 45 | }, 46 | complete () { 47 | completeMask |= 1 << i 48 | tryComplete() 49 | } 50 | }) 51 | ) 52 | 53 | return () => subscriptions.forEach(s => s.unsubscribe()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/bulb/src/combinators/zipWith.test.js: -------------------------------------------------------------------------------- 1 | import { always } from 'fkit' 2 | 3 | import mockSignal from '../internal/mockSignal' 4 | import zipWith from './zipWith' 5 | 6 | let s, t, u 7 | let next, error, complete 8 | let emit 9 | 10 | describe('zipWith', () => { 11 | beforeEach(() => { 12 | s = mockSignal() 13 | t = mockSignal() 14 | u = mockSignal() 15 | 16 | next = jest.fn() 17 | error = jest.fn() 18 | complete = jest.fn() 19 | 20 | emit = { next, error, complete } 21 | }) 22 | 23 | it('zips the corresponding signal values using a function', () => { 24 | const f = jest.fn((a, b, c) => a + b + c) 25 | 26 | zipWith(f, [s, t, u])(emit) 27 | 28 | s.next(1) 29 | expect(next).not.toHaveBeenCalled() 30 | t.next(2) 31 | expect(next).not.toHaveBeenCalled() 32 | u.next(3) 33 | expect(f).toHaveBeenLastCalledWith(1, 2, 3) 34 | expect(next).toHaveBeenLastCalledWith(6) 35 | }) 36 | 37 | it('emits an error when any of the given signals emit an error', () => { 38 | zipWith(always(), [s, t])(emit) 39 | 40 | expect(error).not.toHaveBeenCalled() 41 | s.error('foo') 42 | expect(error).toHaveBeenCalledTimes(1) 43 | expect(error).toHaveBeenLastCalledWith('foo') 44 | t.error('bar') 45 | expect(error).toHaveBeenCalledTimes(2) 46 | expect(error).toHaveBeenLastCalledWith('bar') 47 | }) 48 | 49 | it('completes when all of the given signals are completed', () => { 50 | zipWith(always(), [s, t])(emit) 51 | 52 | s.complete() 53 | expect(complete).not.toHaveBeenCalled() 54 | t.complete() 55 | expect(complete).toHaveBeenCalledTimes(1) 56 | }) 57 | 58 | it('unmounts the given signals when the unsubscribe function is called', () => { 59 | const unsubscribe = zipWith(always(), [s, t])(emit) 60 | 61 | expect(s.unmount).not.toHaveBeenCalled() 62 | expect(t.unmount).not.toHaveBeenCalled() 63 | unsubscribe() 64 | expect(s.unmount).toHaveBeenCalledTimes(1) 65 | expect(t.unmount).toHaveBeenCalledTimes(1) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /packages/bulb/src/index.js: -------------------------------------------------------------------------------- 1 | export { Bus } from './Bus' 2 | export { Signal } from './Signal' 3 | -------------------------------------------------------------------------------- /packages/bulb/src/internal/mockSignal.js: -------------------------------------------------------------------------------- 1 | import { Signal } from '../Signal' 2 | 3 | /** 4 | * Creates a mock signal to be used for testing. 5 | * 6 | * @private 7 | */ 8 | export default function mockSignal () { 9 | let next, error, complete 10 | const unmount = jest.fn() 11 | const s = new Signal(emit => { 12 | next = emit.next 13 | error = emit.error 14 | complete = emit.complete 15 | return unmount 16 | }) 17 | s.next = a => { next && next(a) } 18 | s.error = e => { error && error(e) } 19 | s.complete = () => { complete && complete() } 20 | s.unmount = unmount 21 | return s 22 | } 23 | -------------------------------------------------------------------------------- /packages/bulb/src/scheduler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Executes the function `f` asynchronously. 3 | * 4 | * @private 5 | */ 6 | export function asap (f) { 7 | setTimeout(f, 0) 8 | } 9 | --------------------------------------------------------------------------------