├── .npmignore ├── .babelrc ├── .gitignore ├── es-observable-tests ├── index.js ├── package.json └── README.md ├── run-tests.js ├── util └── publish-gh.sh ├── package.json ├── test ├── future │ ├── symbol-species.js │ ├── map.js │ ├── filter.js │ └── forEach.js ├── symbol-observable.js ├── default.js ├── of.js ├── observer-closed.js ├── constructor.js ├── helpers.js ├── observer-next.js ├── observer-complete.js ├── observer-error.js ├── from.js └── subscribe.js ├── demo ├── parser.js └── mouse-drags.js ├── spec ├── subscription.html ├── index.html ├── constructor-properties.html ├── prototype-properties.html └── subscription-observer.html ├── dom-event-dispatch.md ├── README.md ├── src └── Observable.js ├── Why error and complete.md ├── ObservableEventTarget.md └── Can Observable be built on Cancel Tokens.md /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | _* 3 | ObservableTests 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-es2015-modules-commonjs"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | _* 3 | node_modules 4 | ObservableTests 5 | commonjs 6 | -------------------------------------------------------------------------------- /es-observable-tests/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./es-observable-tests.js"); 2 | -------------------------------------------------------------------------------- /run-tests.js: -------------------------------------------------------------------------------- 1 | import { Observable } from "./src/Observable.js"; 2 | import { runTests } from "./test/default.js"; 3 | 4 | runTests(Observable); 5 | -------------------------------------------------------------------------------- /util/publish-gh.sh: -------------------------------------------------------------------------------- 1 | ecmarkup spec/index.html _index.html || git checkout gh-pages || mv _index.html index.html || git commit -a -m 'Update spec' || git push origin || git checkout master 2 | -------------------------------------------------------------------------------- /es-observable-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "es-observable-tests", 3 | "version": "0.3.0", 4 | "description": "Unit tests for es-observable", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/zenparsing/es-observable" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /es-observable-tests/README.md: -------------------------------------------------------------------------------- 1 | ## ECMAScript Observable Tests ## 2 | 3 | To run the unit tests, install the **es-observable-tests** package into your project. 4 | 5 | ``` 6 | npm install es-observable-tests 7 | ``` 8 | 9 | Then call the exported `runTests` function with the constructor you want to test. 10 | 11 | ```js 12 | require("es-observable-tests").runTests(MyObservable); 13 | ``` 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "es-observable", 3 | "private": true, 4 | "scripts": { 5 | "test": "babel-node run-tests.js", 6 | "prepare": "babel -d commonjs/src/ src/ && babel -d commonjs/test/ test/ && babel -d commonjs/ run-tests.js" 7 | }, 8 | "main": "commonjs/src/Observable.js", 9 | "version": "0.3.0", 10 | "devDependencies": { 11 | "babel-cli": "^6.26.0", 12 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 13 | "moon-unit": "^0.2.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/future/symbol-species.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty, getSymbol } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "Observable has a species method" (test, { Observable }) { 6 | 7 | testMethodProperty(test, Observable, getSymbol("species"), { 8 | get: true, 9 | configurable: true 10 | }); 11 | }, 12 | 13 | "Return value" (test, { Observable }) { 14 | 15 | let desc = Object.getOwnPropertyDescriptor(Observable, getSymbol("species")), 16 | thisVal = {}; 17 | 18 | test._("Returns the 'this' value").equals(desc.get.call(thisVal), thisVal); 19 | } 20 | 21 | }; 22 | -------------------------------------------------------------------------------- /test/symbol-observable.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty, getSymbol } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "Observable.prototype has a Symbol.observable method" (test, { Observable }) { 6 | 7 | testMethodProperty(test, Observable.prototype, getSymbol("observable"), { 8 | configurable: true, 9 | writable: true, 10 | length: 0 11 | }); 12 | }, 13 | 14 | "Return value" (test, { Observable }) { 15 | 16 | let desc = Object.getOwnPropertyDescriptor(Observable.prototype, getSymbol("observable")), 17 | thisVal = {}; 18 | 19 | test._("Returns the 'this' value").equals(desc.value.call(thisVal), thisVal); 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /test/default.js: -------------------------------------------------------------------------------- 1 | import { TestRunner } from "moon-unit"; 2 | 3 | import constructor from "./constructor.js"; 4 | import subscribe from "./subscribe.js"; 5 | import observable from "./symbol-observable.js"; 6 | import ofTests from "./of.js"; 7 | import fromTests from "./from.js"; 8 | 9 | import observerNext from "./observer-next.js"; 10 | import observerError from "./observer-error.js"; 11 | import observerComplete from "./observer-complete.js"; 12 | import observerClosed from "./observer-closed.js"; 13 | 14 | 15 | export function runTests(C) { 16 | 17 | return new TestRunner().inject({ Observable: C }).run({ 18 | 19 | "Observable constructor": constructor, 20 | 21 | "Observable.prototype.subscribe": subscribe, 22 | "Observable.prototype[Symbol.observable]": observable, 23 | 24 | "Observable.of": ofTests, 25 | "Observable.from": fromTests, 26 | 27 | "SubscriptionObserver.prototype.next": observerNext, 28 | "SubscriptionObserver.prototype.error": observerError, 29 | "SubscriptionObserver.prototype.complete": observerComplete, 30 | "SubscriptionObserver.prototype.closed": observerClosed, 31 | 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /test/of.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | // TODO: Verify that Observable.from subscriber returns a cleanup function 4 | 5 | export default { 6 | 7 | "Observable has an of property" (test, { Observable }) { 8 | 9 | testMethodProperty(test, Observable, "of", { 10 | configurable: true, 11 | writable: true, 12 | length: 0, 13 | }); 14 | }, 15 | 16 | "Uses the this value if it's a function" (test, { Observable }) { 17 | 18 | let usesThis = false; 19 | 20 | Observable.of.call(function(_) { usesThis = true; }); 21 | test._("Observable.of will use the 'this' value if it is callable") 22 | .equals(usesThis, true); 23 | }, 24 | 25 | "Uses 'Observable' if the 'this' value is not a function" (test, { Observable }) { 26 | 27 | let result = Observable.of.call({}, 1, 2, 3, 4); 28 | 29 | test._("Observable.of will use 'Observable' if the this value is not callable") 30 | .assert(result instanceof Observable); 31 | }, 32 | 33 | "Arguments are delivered to next" (test, { Observable }) { 34 | 35 | let values = [], 36 | turns = 0; 37 | 38 | Observable.of(1, 2, 3, 4).subscribe({ 39 | 40 | next(v) { 41 | values.push(v); 42 | }, 43 | 44 | complete() { 45 | test._("All items are delivered and complete is called") 46 | .equals(values, [1, 2, 3, 4]); 47 | }, 48 | }); 49 | }, 50 | 51 | }; 52 | -------------------------------------------------------------------------------- /test/observer-closed.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "SubscriptionObserver.prototype has a closed getter" (test, { Observable }) { 6 | 7 | let observer; 8 | new Observable(x => { observer = x }).subscribe({}); 9 | 10 | testMethodProperty(test, Object.getPrototypeOf(observer), "closed", { 11 | get: true, 12 | configurable: true, 13 | writable: true, 14 | length: 1 15 | }); 16 | }, 17 | 18 | "Returns false when the subscription is active" (test, { Observable }) { 19 | new Observable(observer => { 20 | test._("Returns false when the subscription is active") 21 | .equals(observer.closed, false); 22 | }).subscribe({}); 23 | }, 24 | 25 | "Returns true when the subscription is closed" (test, { Observable }) { 26 | new Observable(observer => { 27 | observer.complete(); 28 | test._("Returns true after complete is called") 29 | .equals(observer.closed, true); 30 | }).subscribe({}); 31 | 32 | new Observable(observer => { 33 | observer.error(1); 34 | test._("Returns true after error is called") 35 | .equals(observer.closed, true); 36 | }).subscribe({ error() {} }); 37 | 38 | let observer; 39 | 40 | new Observable(x => { observer = x }).subscribe({}).unsubscribe(); 41 | test._("Returns true after unsubscribe is called") 42 | .equals(observer.closed, true); 43 | }, 44 | 45 | }; 46 | -------------------------------------------------------------------------------- /test/constructor.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "Observable should be called as a constructor with new operator" (test, { Observable }) { 6 | 7 | test 8 | ._("It cannot be called as a function") 9 | .throws(_ => Observable(function() {}), TypeError) 10 | .throws(_ => Observable.call({}, function() {}), TypeError) 11 | ; 12 | }, 13 | 14 | "Argument types" (test, { Observable }) { 15 | 16 | test 17 | ._("The first argument cannot be a non-callable object") 18 | .throws(_=> new Observable({}), TypeError) 19 | ._("The first argument cannot be a primative value") 20 | .throws(_=> new Observable(false), TypeError) 21 | .throws(_=> new Observable(null), TypeError) 22 | .throws(_=> new Observable(undefined), TypeError) 23 | .throws(_=> new Observable(1), TypeError) 24 | ._("The first argument can be a function") 25 | .not().throws(_=> new Observable(function() {})) 26 | ; 27 | }, 28 | 29 | "Observable.prototype has a constructor property" (test, { Observable }) { 30 | 31 | testMethodProperty(test, Observable.prototype, "constructor", { 32 | configurable: true, 33 | writable: true, 34 | length: 1, 35 | }); 36 | 37 | test._("Observable.prototype.constructor === Observable") 38 | .equals(Observable.prototype.constructor, Observable); 39 | }, 40 | 41 | "Subscriber function is not called by constructor" (test, { Observable }) { 42 | 43 | let called = 0; 44 | new Observable(_=> called++); 45 | 46 | test 47 | ._("The constructor does not call the subscriber function") 48 | .equals(called, 0) 49 | ; 50 | }, 51 | 52 | }; 53 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | function testLength(test, value, length) { 2 | 3 | if (typeof value !== "function" || typeof length !== "number") 4 | return; 5 | 6 | test._("Function length is " + length) 7 | .equals(value.length, length); 8 | } 9 | 10 | export function testMethodProperty(test, object, key, options) { 11 | 12 | let desc = Object.getOwnPropertyDescriptor(object, key); 13 | 14 | test._(`Property ${ key.toString() } exists on the object`) 15 | .equals(Boolean(desc), true); 16 | 17 | if (!desc) 18 | return; 19 | 20 | if (options.get || options.set) { 21 | 22 | test._(`Property ${ options.get ? "has" : "does not have" } a getter`) 23 | .equals(typeof desc.get, options.get ? "function" : "undefined"); 24 | 25 | testLength(test, desc.get, 0); 26 | 27 | test._(`Property ${ options.set ? "has" : "does not have" } a setter`) 28 | .equals(typeof desc.set, options.set ? "function" : "undefined"); 29 | 30 | testLength(test, desc.set, 1); 31 | 32 | } else { 33 | 34 | test._("Property has a function value") 35 | .equals(typeof desc.value, "function"); 36 | 37 | testLength(test, desc.value, options.length); 38 | 39 | test._(`Property is ${ options.writable ? "" : "non-" }writable`) 40 | .equals(desc.writable, Boolean(options.writable)); 41 | } 42 | 43 | 44 | test 45 | ._(`Property is ${ options.enumerable ? "" : "non-" }enumerable`) 46 | .equals(desc.enumerable, Boolean(options.enumerable)) 47 | ._(`Property is ${ options.configurable ? "" : "non-" }configurable`) 48 | .equals(desc.configurable, Boolean(options.configurable)) 49 | ; 50 | 51 | } 52 | 53 | export function hasSymbol(name) { 54 | 55 | return typeof Symbol === "function" && Boolean(Symbol[name]); 56 | } 57 | 58 | export function getSymbol(name) { 59 | 60 | return hasSymbol(name) ? Symbol[name] : "@@" + name; 61 | } 62 | -------------------------------------------------------------------------------- /demo/parser.js: -------------------------------------------------------------------------------- 1 | import { Observable } from "../src/Observable.js"; 2 | 3 | // A sequence of token objects 4 | const TOKENS = [ 5 | 6 | { type: "NUMBER", value: 123 }, 7 | { type: "+" }, 8 | { type: "NUMBER", value: 89 }, 9 | { type: "*" }, 10 | { type: "NUMBER", value: 76 }, 11 | ]; 12 | 13 | // Returns an observable sequence of token objects 14 | function tokenStream() { 15 | 16 | return Observable.from(TOKENS); 17 | } 18 | 19 | // Returns an observable which outputs an AST from an input observable of token objects 20 | function parse(tokenStream) { 21 | 22 | let current = null; 23 | 24 | function* peek() { 25 | 26 | if (current === null) 27 | current = yield; 28 | 29 | return current; 30 | } 31 | 32 | function* eat(type = "") { 33 | 34 | let token = yield * peek(); 35 | 36 | if (type && token.type !== type) 37 | throw new SyntaxError("Expected " + type); 38 | 39 | current = null; 40 | return token; 41 | } 42 | 43 | function* parseAdd() { 44 | 45 | let node = yield * parseMultiply(); 46 | 47 | while ((yield * peek()).type === "+") { 48 | 49 | yield * eat(); 50 | let right = yield * parseMultiply() 51 | node = { type: "+", left: node, right, value: node.value + right.value }; 52 | } 53 | 54 | return node; 55 | } 56 | 57 | function* parseMultiply() { 58 | 59 | let node = yield * eat("NUMBER"); 60 | 61 | while ((yield * peek()).type === "*") { 62 | 63 | yield * eat(); 64 | let right = yield * eat("NUMBER"); 65 | node = { type: "*", left: node, right, value: node.value * right.value }; 66 | } 67 | 68 | return node; 69 | } 70 | 71 | function* start() { 72 | 73 | let ast = yield * parseAdd(); 74 | yield * eat("EOF"); 75 | return ast; 76 | }; 77 | 78 | return new Observable(sink => { 79 | 80 | let generator = start(); 81 | generator.next(); 82 | 83 | return tokenStream.subscribe({ 84 | 85 | next(x) { 86 | 87 | let result; 88 | 89 | try { result = generator.next(x) } 90 | catch (x) { return sink.error(x) } 91 | 92 | if (result.done) 93 | sink.complete(result.value); 94 | 95 | return result; 96 | }, 97 | 98 | error(x) { return sink.error(x) }, 99 | complete() { return this.next({ type: "EOF" }) }, 100 | }); 101 | }); 102 | } 103 | 104 | parse(tokenStream()).subscribe({ 105 | complete(ast) { console.log(ast) }, 106 | error(error) { console.log(error) }, 107 | }); 108 | -------------------------------------------------------------------------------- /demo/mouse-drags.js: -------------------------------------------------------------------------------- 1 | // Emits each element of the input stream until the control stream has emitted an 2 | // element. 3 | function takeUntil(stream, control) { 4 | 5 | return new Observable(sink => { 6 | 7 | let source = stream.subscribe(sink); 8 | 9 | let input = control.subscribe({ 10 | 11 | next: x => sink.complete(x), 12 | error: x => sink.error(x), 13 | complete: x => sink.complete(x), 14 | }); 15 | 16 | return _=> { 17 | 18 | source.unsubscribe(); 19 | input.unsubscribe(); 20 | }; 21 | }); 22 | } 23 | 24 | // For a nested stream, emits the elements of the inner stream contained within the 25 | // most recent outer stream 26 | function switchLatest(stream) { 27 | 28 | return new Observable(sink => { 29 | 30 | let inner = null; 31 | 32 | let outer = stream.subscribe({ 33 | 34 | next(value) { 35 | 36 | if (inner) 37 | inner.unsubscribe(); 38 | 39 | inner = value.subscribe({ 40 | 41 | next: x => sink.next(x), 42 | error: x => sink.error(x), 43 | }); 44 | }, 45 | 46 | error: x => sink.error(x), 47 | complete: x => sink.complete(x), 48 | 49 | }); 50 | 51 | return _=> { 52 | 53 | if (inner) 54 | inner.unsubscribe(); 55 | 56 | outer.unsubscribe(); 57 | }; 58 | }); 59 | } 60 | 61 | // Returns an observable of DOM element events 62 | function listen(element, eventName) { 63 | 64 | return new Observable(sink => { 65 | 66 | function handler(event) { sink.next(event) } 67 | element.addEventListener(eventName, handler); 68 | return _=> element.removeEventListener(eventName, handler); 69 | }); 70 | } 71 | 72 | // Returns an observable of drag move events for the specified element 73 | function mouseDrags(element) { 74 | 75 | // For each mousedown, emit a nested stream of mouse move events which stops 76 | // when a mouseup event occurs 77 | let moveStreams = listen(element, "mousedown").map(e => { 78 | 79 | e.preventDefault(); 80 | 81 | return takeUntil( 82 | listen(element, "mousemove"), 83 | listen(document, "mouseup")); 84 | }); 85 | 86 | // Return a stream of mouse moves nested within the most recent mouse down 87 | return switchLatest(moveStreams); 88 | } 89 | 90 | let subscription = mouseDrags(document.body).subscribe({ 91 | next(e) { console.log(`DRAG: <${ e.x }:${ e.y }>`) } 92 | }); 93 | 94 | /* 95 | 96 | (async _=> { 97 | 98 | for await (let e of mouseDrags(document.body)) 99 | console.log(`DRAG: <${ e.x }:${ e.y }>`); 100 | 101 | })(); 102 | 103 | */ 104 | -------------------------------------------------------------------------------- /spec/subscription.html: -------------------------------------------------------------------------------- 1 | 2 |

Subscription Abstract Operations

3 | 4 | 5 |

CreateSubscription ( _observer_ )

6 | 7 |

The abstract operation CreateSubscription with argument _observer_ is used to create a Subscription object. It performs the following steps:

8 | 9 | 10 | 1. Assert: Type(_observer_) is Object. 11 | 1. Let _subscription_ be ObjectCreate(%SubscriptionPrototype%, «‍ [[Observer]], [[Cleanup]] »). 12 | 1. Set _subscription_'s [[Observer]] internal slot to _observer_. 13 | 1. Set _subscription_'s [[Cleanup]] internal slot to *undefined*. 14 | 1. Return _subscription_. 15 | 16 |
17 | 18 | 19 |

CleanupSubscription ( _subscription_ )

20 | 21 |

The abstract operation CleanupSubscription with argument _subscription_ performs the following steps:

22 | 23 | 24 | 1. Assert: _subscription_ is a Subscription object. 25 | 1. Let _cleanup_ be the value of _subscription_'s [[Cleanup]] internal slot. 26 | 1. If _cleanup_ is *undefined*, return *undefined*. 27 | 1. Assert: IsCallable(_cleanup_) is *true*. 28 | 1. Set _subscription_'s [[Cleanup]] internal slot to *undefined*. 29 | 1. Let _result_ be Call(_cleanup_, *undefined*, «‍ »). 30 | 1. If _result_ is an abrupt completion, perform HostReportErrors(« _result_.[[Value]] »). 31 | 1. Return *undefined*. 32 | 33 |
34 | 35 | 36 |

SubscriptionClosed ( _subscription_ )

37 | 38 |

The abstract operation SubscriptionClosed with argument _subscription_ performs the following steps:

39 | 40 | 41 | 1. Assert: _subscription_ is a Subscription object. 42 | 1. If the value of _subscription_'s [[Observer]] internal slot is *undefined*, return *true*. 43 | 1. Else, return *false*. 44 | 45 |
46 | 47 |
48 | 49 | 50 |

The %SubscriptionPrototype% Object

51 | 52 |

All Subscription objects inherit properties from the %SubscriptionPrototype% intrinsic object. The %SubscriptionPrototype% object is an ordinary object and its [[Prototype]] internal slot is the %ObjectPrototype% intrinsic object. In addition, %SubscriptionPrototype% has the following properties:

53 | 54 | 55 |

get %SubscriptionPrototype%.closed

56 | 57 | 1. Let _subscription_ be the *this* value. 58 | 1. If Type(_subscription_) is not Object, throw a *TypeError* exception. 59 | 1. If _subscription_ does not have all of the internal slots of a Subscription instance, throw a *TypeError* exception. 60 | 1. Return ! SubscriptionClosed(_subscription_). 61 | 62 |
63 | 64 | 65 |

%SubscriptionPrototype%.unsubscribe ( )

66 | 67 | 1. Let _subscription_ be the *this* value. 68 | 1. If Type(_subscription_) is not Object, throw a *TypeError* exception. 69 | 1. If _subscription_ does not have all of the internal slots of a Subscription instance, throw a *TypeError* exception. 70 | 1. If SubscriptionClosed(_subscription_) is *true*, return *undefined*. 71 | 1. Set _subscription_'s [[Observer]] internal slot to *undefined*. 72 | 1. Return CleanupSubscription(_subscription_). 73 | 74 |
75 |
76 | -------------------------------------------------------------------------------- /dom-event-dispatch.md: -------------------------------------------------------------------------------- 1 | ## Implementing EventTarget with Observable ## 2 | 3 | ### Changes to Event Dispatch ### 4 | 5 | We modify the "invoke event listener" algorithm so that each event listener holds a 6 | reference to an **observer**. The observer's **next** method is invoked with the 7 | current event. 8 | 9 | #### Invoking Event Listeners #### 10 | 11 | To invoke the event listeners for an object with an event run these steps: 12 | 13 | 1. Let *event* be the event for which the event listeners are invoked. 14 | 1. Let *observers* be a copy of the event listeners associated with the object for which 15 | these steps are run. 16 | 1. Initialize *event*'s currentTarget attribute to the object for which these steps are 17 | run. 18 | 1. Then run these substeps for each event *listener* in *listeners*: 19 | 1. If *event*'s stop immediate propagation flag is set, terminate the **invoke** 20 | algorithm. 21 | 1. Let *listener* be the event listener. 22 | 1. If *event*'s type attribute value is not *listener*'s type, terminate these substeps 23 | (and run them for the next event listener). 24 | 1. If *event*'s eventPhase attribute value is **CAPTURING_PHASE** and listener's 25 | capture is **false**, terminate these substeps (and run them for the next event 26 | listener). 27 | 1. If *event*'s eventPhase attribute value is **BUBBLING_PHASE** and listener's 28 | capture is **true**, terminate these substeps (and run them for the next event 29 | listener). 30 | 1. Let *observer* be the *listener*'s observer. 31 | 1. Invoke the `next` method of *observer*, with the event passed to this algorithm 32 | as the first argument. If this throws any exception, report the exception. 33 | 34 | ### EventTarget Implementation in JavaScript ### 35 | 36 | ```js 37 | function findHandler(list, type, handler, capture) { 38 | return list.findIndex(x => 39 | x.type === type && 40 | x.handler === handler && 41 | x.capture === capture); 42 | } 43 | 44 | class EventTarget { 45 | 46 | @listeners = []; 47 | @handlers = []; 48 | 49 | on(type, capture = false) { 50 | 51 | return new Observable(observer => { 52 | 53 | // On subscription, add a listener 54 | this.@listeners.push({ observer, type, capture }); 55 | 56 | // On unsubscription, remove the listener 57 | return _=> { 58 | 59 | let index = this.@listeners.findIndex( 60 | listener => listener.observer === observer); 61 | 62 | if (index >= 0) 63 | this.@listeners.splice(index, 1); 64 | }; 65 | }); 66 | } 67 | 68 | addEventListener(type, handler, capture = false) { 69 | 70 | let index = findHandler(this.@handlers, type, handler, capture); 71 | 72 | // Dedupe: exit if listener is already registered 73 | if (index >= 0) 74 | return; 75 | 76 | // Subscribe to the event stream 77 | let subscription = this.on(type, capture).subscribe({ 78 | next(event) { handler.call(event.currentTarget, event) } 79 | }); 80 | 81 | // Store the handler and subscription 82 | this.@handlers.push({ type, handler, capture, subscription }); 83 | } 84 | 85 | removeEventListener(type, handler, capture = false) { 86 | 87 | let index = findHandler(this.@handlers, type, handler, capture); 88 | 89 | // Exit if listener is not registered 90 | if (index < 0) 91 | return; 92 | 93 | // Call the cancellation function and remove the handler 94 | this.@handlers[index].subscription.unsubscribe(); 95 | this.@handlers.splice(index, 1); 96 | } 97 | 98 | } 99 | ``` 100 | -------------------------------------------------------------------------------- /test/observer-next.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "SubscriptionObserver.prototype has an next method" (test, { Observable }) { 6 | 7 | let observer; 8 | new Observable(x => { observer = x }).subscribe({}); 9 | 10 | testMethodProperty(test, Object.getPrototypeOf(observer), "next", { 11 | configurable: true, 12 | writable: true, 13 | length: 1 14 | }); 15 | }, 16 | 17 | "Input value" (test, { Observable }) { 18 | 19 | let token = {}; 20 | 21 | new Observable(observer => { 22 | 23 | observer.next(token, 1, 2); 24 | 25 | }).subscribe({ 26 | 27 | next(value, ...args) { 28 | test._("Input value is forwarded to the observer") 29 | .equals(value, token) 30 | ._("Additional arguments are not forwarded") 31 | .equals(args.length, 0); 32 | } 33 | 34 | }); 35 | }, 36 | 37 | "Return value" (test, { Observable }) { 38 | 39 | let token = {}; 40 | 41 | new Observable(observer => { 42 | 43 | test._("Suppresses the value returned from the observer") 44 | .equals(observer.next(), undefined); 45 | 46 | observer.complete(); 47 | 48 | test._("Returns undefined when closed") 49 | .equals(observer.next(), undefined); 50 | 51 | }).subscribe({ 52 | next() { return token } 53 | }); 54 | }, 55 | 56 | "Thrown error" (test, { Observable }) { 57 | 58 | let token = {}; 59 | 60 | new Observable(observer => { 61 | 62 | test._("Catches errors thrown from the observer") 63 | .equals(observer.next(), undefined); 64 | 65 | }).subscribe({ 66 | next() { throw new Error(); } 67 | }); 68 | }, 69 | 70 | "Method lookup" (test, { Observable }) { 71 | 72 | let observer, 73 | observable = new Observable(x => { observer = x }); 74 | 75 | observable.subscribe({}); 76 | test._("If property does not exist, then next returns undefined") 77 | .equals(observer.next(), undefined); 78 | 79 | observable.subscribe({ next: undefined }); 80 | test._("If property is undefined, then next returns undefined") 81 | .equals(observer.next(), undefined); 82 | 83 | observable.subscribe({ next: null }); 84 | test._("If property is null, then next returns undefined") 85 | .equals(observer.next(), undefined); 86 | 87 | observable.subscribe({ next: {} }); 88 | test._("If property is not a function, then next returns undefined") 89 | .equals(observer.next(), undefined); 90 | 91 | let actual = {}; 92 | let calls = 0; 93 | observable.subscribe(actual); 94 | actual.next = (_=> calls++); 95 | test._("Method is not accessed until complete is called") 96 | .equals(observer.next() || calls, 1); 97 | 98 | let called = 0; 99 | observable.subscribe({ 100 | get next() { 101 | called++; 102 | return function() {}; 103 | } 104 | }); 105 | observer.complete(); 106 | observer.next(); 107 | test._("Method is not accessed when subscription is closed") 108 | .equals(called, 0); 109 | 110 | called = 0; 111 | observable.subscribe({ 112 | get next() { 113 | called++; 114 | return function() {}; 115 | } 116 | }); 117 | observer.next(); 118 | test._("Property is only accessed once during a lookup") 119 | .equals(called, 1); 120 | 121 | }, 122 | 123 | "Cleanup functions" (test, { Observable }) { 124 | 125 | let observer; 126 | 127 | let observable = new Observable(x => { 128 | observer = x; 129 | return _=> { }; 130 | }); 131 | 132 | let subscription = observable.subscribe({ next() { throw new Error() } }); 133 | observer.next() 134 | test._("Subscription is not closed when next throws an error") 135 | .equals(subscription.closed, false); 136 | }, 137 | 138 | }; 139 | -------------------------------------------------------------------------------- /spec/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
 4 | title: Observable
 5 | status: proposal
 6 | stage: 1
 7 | location: https://github.com/zenparsing/es-observable
 8 | copyright: false
 9 | contributors: Kevin Smith
10 | 
11 | 12 | 13 |

The Observable Constructor

14 | 15 |

The Observable constructor is the %Observable% intrinsic object and the initial value of the Observable property of the global object. When called as a constructor it creates and initializes a new Observable object. Observable is not intended to be called as a function and will throw an exception when called in that manner.

16 | 17 |

The Observable constructor is designed to be subclassable. It may be used as the value in an extends clause of a class definition. Subclass constructors that intend to inherit the specified Observable behaviour must include a super call to the Observable constructor to create and initialize the subclass instance with the internal state necessary to support the Observable and Observable.prototype built-in methods.

18 | 19 | 20 |

Observable ( _subscriber_ )

21 | 22 |

The `Observable` constructor initializes a new Observable object. It is not intended to be called as a function and will throw an exception when called in that manner.

23 | 24 |

The _subscriber_ argument must be a function object. It is called each time the `subscribe` method of the Observable object is invoked. The _subscriber_ function is called with a wrapped observer object and may optionally return a function which will cancel the subscription.

25 | 26 |

The `Observable` constructor performs the following steps:

27 | 28 | 29 | 1. If NewTarget is *undefined*, throw a *TypeError* exception. 30 | 1. If IsCallable(_subscriber_) is *false*, throw a *TypeError* exception. 31 | 1. Let _observable_ be ? OrdinaryCreateFromConstructor(NewTarget, %ObservablePrototype%, «‍ [[Subscriber]] »). 32 | 1. Set _observable's_ [[Subscriber]] internal slot to _subscriber_. 33 | 1. Return _observable_. 34 | 35 |
36 | 37 |
38 | 39 | 40 |

Properties of the Observable Constructor

41 | 42 |

The value of the [[Prototype]] internal slot of the `Observable` constructor is the intrinsic object %FunctionPrototype%.

43 | 44 |

Besides the `length` property (whose value is 1), the `Observable` constructor has the following properties:

45 | 46 | 47 |
48 | 49 | 50 |

Properties of the Observable Prototype Object

51 | 52 |

The `Observable` prototype object is the intrinsic object %ObservablePrototype%. The value of the [[Prototype]] internal slot of the `Observable` prototype object is the intrinsic object %ObjectPrototype%. The `Observable` prototype object is an ordinary object.

53 | 54 | 55 |
56 | 57 | 58 |

Subscription Objects

59 | 60 |

A Subscription is an object which represents a channel through which an Observable may send data to an Observer.

61 | 62 | 63 |
64 | 65 | 66 |

Subscription Observer Objects

67 | 68 |

A Subscription Observer is an object which wraps the _observer_ argument supplied to the `subscribe` method of Observable objects. Subscription Observer objects are passed as the single parameter to an observable's _subscriber_ function. They enforce the following guarantees:

69 | 70 | 75 | 76 |

In addition, Subscription Observer objects provide default behaviors when the observer does not implement `next`, `error` or `complete`.

77 | 78 | 79 |
80 | -------------------------------------------------------------------------------- /test/future/map.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Not currently part of the es-observable specification 4 | 5 | */ 6 | 7 | import { testMethodProperty, getSymbol } from "./helpers.js"; 8 | 9 | export default { 10 | 11 | "Observable.prototype has a map property" (test, { Observable }) { 12 | 13 | testMethodProperty(test, Observable.prototype, "map", { 14 | configurable: true, 15 | writable: true, 16 | length: 1, 17 | }); 18 | }, 19 | 20 | "Allowed arguments" (test, { Observable }) { 21 | 22 | let observable = new Observable(_=> null); 23 | 24 | test._("Argument must be a function") 25 | .throws(_=> observable.map(), TypeError) 26 | .throws(_=> observable.map(null), TypeError) 27 | .throws(_=> observable.map({}), TypeError) 28 | ; 29 | }, 30 | 31 | "Species is used to determine the constructor" (test, { Observable }) { 32 | 33 | let observable = new Observable(_=> null), 34 | token = {}; 35 | 36 | function species() { 37 | this.token = token; 38 | } 39 | 40 | observable.constructor = function() {}; 41 | observable.constructor[getSymbol("species")] = species; 42 | 43 | test._("Constructor species is used as the new constructor") 44 | .equals(observable.map(_=> {}).token, token); 45 | 46 | observable.constructor[getSymbol("species")] = null; 47 | test._("An error is thrown if instance does not have a constructor species") 48 | .throws(_=> observable.map(_=> {}), TypeError); 49 | 50 | observable.constructor = null; 51 | test._("An error is thrown if the instance does not have a constructor") 52 | .throws(_=> observable.map(_=> {}), TypeError); 53 | }, 54 | 55 | "The callback is used to map next values" (test, { Observable }) { 56 | 57 | let values = [], 58 | returns = []; 59 | 60 | new Observable(observer => { 61 | returns.push(observer.next(1)); 62 | returns.push(observer.next(2)); 63 | observer.complete(); 64 | }).map(x => x * 2).subscribe({ 65 | next(v) { values.push(v); return -v; } 66 | }); 67 | 68 | test 69 | ._("Mapped values are sent to the observer") 70 | .equals(values, [2, 4]) 71 | ._("Return values from the observer are returned to the caller") 72 | .equals(returns, [-2, -4]) 73 | ; 74 | }, 75 | 76 | "Errors thrown from the callback are sent to the observer" (test, { Observable }) { 77 | 78 | let error = new Error(), 79 | thrown = null, 80 | returned = null, 81 | token = {}; 82 | 83 | new Observable(observer => { 84 | returned = observer.next(1); 85 | }).map(x => { throw error }).subscribe({ 86 | error(e) { thrown = e; return token; } 87 | }); 88 | 89 | test 90 | ._("Exceptions from callback are sent to the observer") 91 | .equals(thrown, error) 92 | ._("The result of calling error is returned to the caller") 93 | .equals(returned, token) 94 | ; 95 | }, 96 | 97 | "Errors are forwarded to the observer" (test, { Observable }) { 98 | 99 | let error = new Error(), 100 | thrown = null, 101 | returned = null, 102 | token = {}; 103 | 104 | new Observable(observer => { 105 | returned = observer.error(error); 106 | }).map(x => x).subscribe({ 107 | error(e) { thrown = e; return token; } 108 | }); 109 | 110 | test 111 | ._("Error values are forwarded") 112 | .equals(thrown, error) 113 | ._("The return value of the error method is returned to the caller") 114 | .equals(returned, token) 115 | ; 116 | }, 117 | 118 | "Complete is forwarded to the observer" (test, { Observable }) { 119 | 120 | let arg = {}, 121 | passed = null, 122 | returned = null, 123 | token = {}; 124 | 125 | new Observable(observer => { 126 | returned = observer.complete(arg); 127 | }).map(x => x).subscribe({ 128 | complete(v) { passed = v; return token; } 129 | }); 130 | 131 | test 132 | ._("Complete values are forwarded") 133 | .equals(passed, arg) 134 | ._("The return value of the complete method is returned to the caller") 135 | .equals(returned, token) 136 | ; 137 | }, 138 | 139 | }; 140 | -------------------------------------------------------------------------------- /test/future/filter.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Not currently part of the es-observable specification 4 | 5 | */ 6 | 7 | import { testMethodProperty, getSymbol } from "./helpers.js"; 8 | 9 | export default { 10 | 11 | "Observable.prototype has a filter property" (test, { Observable }) { 12 | 13 | testMethodProperty(test, Observable.prototype, "filter", { 14 | configurable: true, 15 | writable: true, 16 | length: 1, 17 | }); 18 | }, 19 | 20 | "Allowed arguments" (test, { Observable }) { 21 | 22 | let observable = new Observable(_=> null); 23 | 24 | test._("Argument must be a function") 25 | .throws(_=> observable.filter(), TypeError) 26 | .throws(_=> observable.filter(null), TypeError) 27 | .throws(_=> observable.filter({}), TypeError) 28 | ; 29 | }, 30 | 31 | "Species is used to determine the constructor" (test, { Observable }) { 32 | 33 | let observable = new Observable(_=> null), 34 | token = {}; 35 | 36 | function species() { 37 | this.token = token; 38 | } 39 | 40 | observable.constructor = function() {}; 41 | observable.constructor[getSymbol("species")] = species; 42 | 43 | test._("Constructor species is used as the new constructor") 44 | .equals(observable.filter(_=> {}).token, token); 45 | 46 | observable.constructor[getSymbol("species")] = null; 47 | test._("An error is thrown if instance does not have a constructor species") 48 | .throws(_=> observable.filter(_=> {}), TypeError); 49 | 50 | observable.constructor = null; 51 | test._("An error is thrown if the instance does not have a constructor") 52 | .throws(_=> observable.filter(_=> {}), TypeError); 53 | }, 54 | 55 | "The callback is used to filter next values" (test, { Observable }) { 56 | 57 | let values = [], 58 | returns = []; 59 | 60 | new Observable(observer => { 61 | returns.push(observer.next(1)); 62 | returns.push(observer.next(2)); 63 | returns.push(observer.next(3)); 64 | returns.push(observer.next(4)); 65 | observer.complete(); 66 | }).filter(x => x % 2).subscribe({ 67 | next(v) { values.push(v); return -v; } 68 | }); 69 | 70 | test 71 | ._("Filtered values are sent to the observer") 72 | .equals(values, [1, 3]) 73 | ._("Return values from the observer are returned to the caller") 74 | .equals(returns, [-1, undefined, -3, undefined]) 75 | ; 76 | }, 77 | 78 | "Errors thrown from the callback are sent to the observer" (test, { Observable }) { 79 | 80 | let error = new Error(), 81 | thrown = null, 82 | returned = null, 83 | token = {}; 84 | 85 | new Observable(observer => { 86 | returned = observer.next(1); 87 | }).filter(x => { throw error }).subscribe({ 88 | error(e) { thrown = e; return token; } 89 | }); 90 | 91 | test 92 | ._("Exceptions from callback are sent to the observer") 93 | .equals(thrown, error) 94 | ._("The result of calling error is returned to the caller") 95 | .equals(returned, token) 96 | ; 97 | }, 98 | 99 | "Errors are forwarded to the observer" (test, { Observable }) { 100 | 101 | let error = new Error(), 102 | thrown = null, 103 | returned = null, 104 | token = {}; 105 | 106 | new Observable(observer => { 107 | returned = observer.error(error); 108 | }).filter(x => true).subscribe({ 109 | error(e) { thrown = e; return token; } 110 | }); 111 | 112 | test 113 | ._("Error values are forwarded") 114 | .equals(thrown, error) 115 | ._("The return value of the error method is returned to the caller") 116 | .equals(returned, token) 117 | ; 118 | }, 119 | 120 | "Complete is forwarded to the observer" (test, { Observable }) { 121 | 122 | let arg = {}, 123 | passed = null, 124 | returned = null, 125 | token = {}; 126 | 127 | new Observable(observer => { 128 | returned = observer.complete(arg); 129 | }).filter(x => true).subscribe({ 130 | complete(v) { passed = v; return token; } 131 | }); 132 | 133 | test 134 | ._("Complete values are forwarded") 135 | .equals(passed, arg) 136 | ._("The return value of the complete method is returned to the caller") 137 | .equals(returned, token) 138 | ; 139 | }, 140 | 141 | }; 142 | -------------------------------------------------------------------------------- /test/future/forEach.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "Observable.prototype has a forEach property" (test, { Observable }) { 6 | 7 | testMethodProperty(test, Observable.prototype, "forEach", { 8 | configurable: true, 9 | writable: true, 10 | length: 1, 11 | }); 12 | }, 13 | 14 | "Argument must be a function" (test, { Observable }) { 15 | 16 | let result = Observable.prototype.forEach.call({}, {}); 17 | 18 | test._("If the first argument is not a function, a promise is returned") 19 | .assert(result instanceof Promise); 20 | 21 | return result.then(_=> null, e => e).then(error => { 22 | 23 | test._("The promise is rejected with a TypeError") 24 | .assert(Boolean(error)) 25 | .assert(error instanceof TypeError); 26 | }); 27 | }, 28 | 29 | "Subscribe is called asynchronously" (test, { Observable }) { 30 | let observer = null, 31 | list = []; 32 | 33 | Promise.resolve().then(_=> list.push(1)); 34 | 35 | let promise = Observable.prototype.forEach.call({ 36 | 37 | subscribe(x) { 38 | list.push(2); 39 | x.complete(); 40 | } 41 | 42 | }, _=> null).then(_=> { 43 | 44 | test._("Subscribe is called in a job").equals(list, [1, 2]); 45 | }); 46 | 47 | test._("Subscribe is not called synchronously").equals(list, []); 48 | return promise; 49 | }, 50 | 51 | "Subscribe is called on the 'this' value" (test, { Observable }) { 52 | 53 | let observer = null, 54 | called = 0; 55 | 56 | return Observable.prototype.forEach.call({ 57 | 58 | subscribe(x) { 59 | called++; 60 | observer = x; 61 | x.complete(); 62 | } 63 | 64 | }, _=> null).then(_=> { 65 | 66 | test._("The subscribe method is called with an observer") 67 | .equals(called, 1) 68 | .equals(typeof observer, "object") 69 | .equals(typeof observer.next, "function") 70 | ; 71 | }); 72 | }, 73 | 74 | "Error rejects the promise" (test, { Observable }) { 75 | 76 | let error = new Error(); 77 | 78 | return new Observable(observer => { observer.error(error) }) 79 | .forEach(_=> null) 80 | .then(_=> null, e => e) 81 | .then(value => { 82 | test._("Sending error rejects the promise with the supplied value") 83 | .equals(value, error); 84 | }); 85 | }, 86 | 87 | "Complete resolves the promise" (test, { Observable }) { 88 | 89 | let token = {}; 90 | 91 | return new Observable(observer => { observer.complete(token) }) 92 | .forEach(_=> null) 93 | .then(x => x, e => null) 94 | .then(value => { 95 | test._("Sending complete resolves the promise with the supplied value") 96 | .equals(value, token); 97 | }); 98 | }, 99 | 100 | "The callback is called with the next value" (test, { Observable }) { 101 | 102 | let values = [], thisArg; 103 | 104 | return new Observable(observer => { 105 | 106 | observer.next(1); 107 | observer.next(2); 108 | observer.next(3); 109 | observer.complete(); 110 | 111 | }).forEach(function(x) { 112 | 113 | thisArg = this; 114 | values.push(x); 115 | 116 | }).then(_=> { 117 | 118 | test 119 | ._("The callback receives each next value") 120 | .equals(values, [1, 2, 3]) 121 | ._("The callback receives undefined as the this value") 122 | .equals(thisArg, undefined); 123 | 124 | }); 125 | }, 126 | 127 | "If the callback throws an error, the promise is rejected" (test, { Observable }) { 128 | 129 | let error = new Error(); 130 | 131 | return new Observable(observer => { observer.next(1) }) 132 | .forEach(_=> { throw error }) 133 | .then(_=> null, e => e) 134 | .then(value => { 135 | test._("The promise is rejected with the thrown value") 136 | .equals(value, error); 137 | }); 138 | }, 139 | 140 | "If the callback throws an error, the callback function is not called again" (test, { Observable }) { 141 | 142 | let callCount = 0; 143 | 144 | return new Observable(observer => { 145 | 146 | observer.next(1); 147 | observer.next(2); 148 | observer.next(3); 149 | 150 | }).forEach(x => { 151 | 152 | callCount++; 153 | throw new Error(); 154 | 155 | }).catch(x => { 156 | 157 | test._("The callback is not called again after throwing an error") 158 | .equals(callCount, 1); 159 | }); 160 | }, 161 | 162 | }; 163 | -------------------------------------------------------------------------------- /spec/constructor-properties.html: -------------------------------------------------------------------------------- 1 | 2 |

Observable.prototype

3 | 4 |

The initial value of `Observable.prototype` is the intrinsic object %ObservablePrototype%.

5 | 6 |

This property has the attributes { [[Writable]]: *false*, [[Enumerable]]: *false*, [[Configurable]]: *false* }.

7 |
8 | 9 | 10 |

Observable.from ( _x_ )

11 | 12 |

When the `from` method is called, the following steps are taken:

13 | 14 | 15 | 1. Let _C_ be the *this* value. 16 | 1. If IsConstructor(C) is *false*, let _C_ be %Observable%. 17 | 1. Let _observableMethod_ be ? GetMethod(_x_, `@@observable`). 18 | 1. If _observableMethod_ is not *undefined*, then 19 | 1. Let _observable_ be ? Call(_observableMethod_, _x_, «‍ »). 20 | 1. If Type(_observable_) is not Object, throw a *TypeError* exception. 21 | 1. Let _constructor_ be ? Get(_observable_, `"constructor"`). 22 | 1. If SameValue(_constructor_, _C_) is *true*, return _observable_. 23 | 1. Let _subscriber_ be a new built-in function object as defined in Observable.from Delegating Functions. 24 | 1. Set _subscriber_'s [[Observable]] internal slot to _observable_. 25 | 1. Else, 26 | 1. Let _iteratorMethod_ be ? GetMethod(_x_, `@@observable`). 27 | 1. If _iteratorMethod_ is *undefined*, throw a *TypeError* exception. 28 | 1. Let _subscriber_ be a new built-in function object as defined in Observable.from Iteration Functions. 29 | 1. Set _subscriber_'s [[Iterable]] internal slot to _x_. 30 | 1. Set _subscriber_'s [[IteratorMethod]] internal slot to _iteratorMethod_. 31 | 1. Return Construct(_C_, « ‍_subscriber_ »). 32 | 33 | 34 | 35 |

Observable.from Delegating Functions

36 | 37 |

An Observable.from delegating function is an anonymous built-in function that has an [[Observable]] internal slot.

38 | 39 |

When an Observable.from delegating function is called with argument _observer_, the following steps are taken:

40 | 41 | 42 | 1. Let _observable_ be the value of the [[Observable]] internal slot of _F_. 43 | 1. Return Invoke(_observable_, `"subscribe"`, « ‍_observer_ »). 44 | 45 | 46 |

The `length` property of an Observable.from delegating function is `1`.

47 |
48 | 49 | 50 |

Observable.from Iteration Functions

51 | 52 |

An Observable.from iteration function is an anonymous built-in function that has [[Iterable]] and [[IteratorFunction]] internal slots.

53 | 54 |

When an Observable.from iteration function is called with argument _observer_, the following steps are taken:

55 | 56 | 57 | 1. Let _iterable_ be the value of the [[Iterable]] internal slot of _F_. 58 | 1. Let _iteratorMethod_ be the value of the [[IteratorMethod]] internal slot of _F_. 59 | 1. Let _iterator_ be ? GetIterator(_items_, _iteratorMethod_). 60 | 1. Let _subscription_ be the value of _observer_'s [[Subscription]] internal slot. 61 | 1. Repeat 62 | 1. Let _next_ be ? IteratorStep(_iterator_). 63 | 1. If _next_ is *false*, then 64 | 1. Perform ! Invoke(_observer_, `"complete"`, «‍ »). 65 | 1. Return *undefined*. 66 | 1. Let _nextValue_ be ? IteratorValue(_next_). 67 | 1. Perform ! Invoke(_observer_, `"next"`, « ‍_nextValue_ »). 68 | 1. If SubscriptionClosed(_subscription_) is *true*, then 69 | 1. Return ? IteratorClose(_iterator_, *undefined*). 70 | 71 | 72 |

The `length` property of an Observable.from iteration function is `1`.

73 |
74 | 75 |
76 | 77 | 78 |

Observable.of ( ..._items_ )

79 | 80 | 81 | 1. Let _C_ be the *this* value. 82 | 1. If IsConstructor(C) is *false*, let _C_ be %Observable%. 83 | 1. Let _subscriber_ be a new built-in function object as defined in Observable.of Subscriber Functions. 84 | 1. Set _subscriber_'s [[Items]] internal slot to _items_. 85 | 1. Return Construct(_C_, « ‍_subscriber_ »). 86 | 87 | 88 | 89 |

Observable.of Subscriber Functions

90 | 91 |

An Observable.of subscriber function is an anonymous built-in function that has an [[Items]] internal slot.

92 | 93 |

When an Observable.of subscriber function is called with argument _observer_, the following steps are taken:

94 | 95 | 96 | 1. Let _items_ be the value of the [[Items]] internal slot of _F_. 97 | 1. Let _subscription_ be the value of _observer_'s [[Subscription]] internal slot. 98 | 1. For each element _value_ of _items_ 99 | 1. Perform ! Invoke(_observer_, `"next"`, « ‍_value_ »). 100 | 1. If SubscriptionClosed(_subscription_) is *true*, then 101 | 1. Return *undefined*. 102 | 1. Perform ! Invoke(_observer_, `"complete"`, «‍ »). 103 | 1. Return *undefined*. 104 | 105 | 106 |

The `length` property of an Observable.of subscriber function is `1`.

107 |
108 | 109 |
110 | -------------------------------------------------------------------------------- /test/observer-complete.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "SubscriptionObserver.prototype has a complete method" (test, { Observable }) { 6 | 7 | let observer; 8 | new Observable(x => { observer = x }).subscribe({}); 9 | 10 | testMethodProperty(test, Object.getPrototypeOf(observer), "complete", { 11 | configurable: true, 12 | writable: true, 13 | length: 0 14 | }); 15 | }, 16 | 17 | "Input value" (test, { Observable }) { 18 | 19 | let token = {}; 20 | 21 | new Observable(observer => { 22 | 23 | observer.complete(token, 1, 2); 24 | 25 | }).subscribe({ 26 | 27 | complete(...args) { 28 | test._("Arguments are not forwarded") 29 | .equals(args.length, 0); 30 | } 31 | 32 | }); 33 | }, 34 | 35 | "Return value" (test, { Observable }) { 36 | 37 | let token = {}; 38 | 39 | new Observable(observer => { 40 | 41 | test._("Suppresses the value returned from the observer") 42 | .equals(observer.complete(), undefined); 43 | 44 | test._("Returns undefined when closed") 45 | .equals(observer.complete(), undefined); 46 | 47 | }).subscribe({ 48 | complete() { return token } 49 | }); 50 | }, 51 | 52 | "Thrown error" (test, { Observable }) { 53 | 54 | let token = {}; 55 | 56 | new Observable(observer => { 57 | 58 | test._("Catches errors thrown from the observer") 59 | .equals(observer.complete(), undefined); 60 | 61 | }).subscribe({ 62 | complete() { throw new Error(); } 63 | }); 64 | }, 65 | 66 | "Method lookup" (test, { Observable }) { 67 | 68 | let observer, 69 | observable = new Observable(x => { observer = x }); 70 | 71 | observable.subscribe({}); 72 | test._("If property does not exist, then complete returns undefined") 73 | .equals(observer.complete(), undefined); 74 | 75 | observable.subscribe({ complete: undefined }); 76 | test._("If property is undefined, then complete returns undefined") 77 | .equals(observer.complete(), undefined); 78 | 79 | observable.subscribe({ complete: null }); 80 | test._("If property is null, then complete returns undefined") 81 | .equals(observer.complete(), undefined); 82 | 83 | observable.subscribe({ complete: {} }); 84 | test._("If property is not a function, then complete returns undefined") 85 | .equals(observer.complete(), undefined); 86 | 87 | let actual = {}; 88 | let calls = 0; 89 | observable.subscribe(actual); 90 | actual.complete = (_=> calls++); 91 | test._("Method is not accessed until complete is called") 92 | .equals(observer.complete() || calls, 1); 93 | 94 | let called = 0; 95 | observable.subscribe({ 96 | get complete() { 97 | called++; 98 | return function() {}; 99 | }, 100 | error() {}, 101 | }); 102 | observer.error(new Error()); 103 | observer.complete(); 104 | test._("Method is not accessed when subscription is closed") 105 | .equals(called, 0); 106 | 107 | called = 0; 108 | observable.subscribe({ 109 | get complete() { 110 | called++; 111 | return function() {}; 112 | } 113 | }); 114 | observer.complete(); 115 | test._("Property is only accessed once during a lookup") 116 | .equals(called, 1); 117 | 118 | called = 0; 119 | observable.subscribe({ 120 | next() { called++ }, 121 | get complete() { 122 | called++; 123 | observer.next(); 124 | return function() { return 1 }; 125 | } 126 | }); 127 | observer.complete(); 128 | test._("When method lookup occurs, subscription is closed") 129 | .equals(called, 1); 130 | 131 | }, 132 | 133 | "Cleanup functions" (test, { Observable }) { 134 | 135 | let called, observer; 136 | 137 | let observable = new Observable(x => { 138 | observer = x; 139 | return _=> { called++ }; 140 | }); 141 | 142 | called = 0; 143 | observable.subscribe({}); 144 | observer.complete(); 145 | test._("Cleanup function is called when observer does not have a complete method") 146 | .equals(called, 1); 147 | 148 | called = 0; 149 | observable.subscribe({ complete() { return 1 } }); 150 | observer.complete(); 151 | test._("Cleanup function is called when observer has a complete method") 152 | .equals(called, 1); 153 | 154 | called = 0; 155 | observable.subscribe({ get complete() { throw new Error() } }); 156 | observer.complete(); 157 | test._("Cleanup function is called when method lookup throws") 158 | .equals(called, 1); 159 | 160 | called = 0; 161 | observable.subscribe({ complete() { throw new Error() } }); 162 | observer.complete(); 163 | test._("Cleanup function is called when method throws") 164 | .equals(called, 1); 165 | }, 166 | 167 | }; 168 | -------------------------------------------------------------------------------- /test/observer-error.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "SubscriptionObserver.prototype has an error method" (test, { Observable }) { 6 | 7 | let observer; 8 | new Observable(x => { observer = x }).subscribe({}); 9 | 10 | testMethodProperty(test, Object.getPrototypeOf(observer), "error", { 11 | configurable: true, 12 | writable: true, 13 | length: 1 14 | }); 15 | }, 16 | 17 | "Input value" (test, { Observable }) { 18 | 19 | let token = {}; 20 | 21 | new Observable(observer => { 22 | 23 | observer.error(token, 1, 2); 24 | 25 | }).subscribe({ 26 | 27 | error(value, ...args) { 28 | test._("Input value is forwarded to the observer") 29 | .equals(value, token) 30 | ._("Additional arguments are not forwarded") 31 | .equals(args.length, 0); 32 | } 33 | 34 | }); 35 | }, 36 | 37 | "Return value" (test, { Observable }) { 38 | 39 | let token = {}; 40 | 41 | new Observable(observer => { 42 | 43 | test._("Suppresses the value returned from the observer") 44 | .equals(observer.error(), undefined); 45 | 46 | test._("Returns undefined when closed") 47 | .equals(observer.error(), undefined); 48 | 49 | }).subscribe({ 50 | error() { return token } 51 | }); 52 | }, 53 | 54 | "Thrown error" (test, { Observable }) { 55 | 56 | let token = {}; 57 | 58 | new Observable(observer => { 59 | 60 | test._("Catches errors thrown from the observer") 61 | .equals(observer.error(), undefined); 62 | 63 | }).subscribe({ 64 | error() { throw new Error(); } 65 | }); 66 | }, 67 | 68 | "Method lookup" (test, { Observable }) { 69 | 70 | let observer, 71 | error = new Error(), 72 | observable = new Observable(x => { observer = x }); 73 | 74 | observable.subscribe({}); 75 | test._("If property does not exist, then error returns undefined") 76 | .equals(observer.error(error), undefined); 77 | 78 | observable.subscribe({ error: undefined }); 79 | test._("If property is undefined, then error returns undefined") 80 | .equals(observer.error(error), undefined); 81 | 82 | observable.subscribe({ error: null }); 83 | test._("If property is null, then error returns undefined") 84 | .equals(observer.error(error), undefined); 85 | 86 | observable.subscribe({ error: {} }); 87 | test._("If property is not a function, then error returns undefined") 88 | .equals(observer.error(error), undefined); 89 | 90 | let actual = {}; 91 | let calls = 0; 92 | observable.subscribe(actual); 93 | actual.error = (_=> calls++); 94 | test._("Method is not accessed until error is called") 95 | .equals(observer.error(error) || calls, 1); 96 | 97 | let called = 0; 98 | observable.subscribe({ 99 | get error() { 100 | called++; 101 | return function() {}; 102 | } 103 | }); 104 | observer.complete(); 105 | try { observer.error(error) } 106 | catch (x) {} 107 | test._("Method is not accessed when subscription is closed") 108 | .equals(called, 0); 109 | 110 | called = 0; 111 | observable.subscribe({ 112 | get error() { 113 | called++; 114 | return function() {}; 115 | } 116 | }); 117 | observer.error(); 118 | test._("Property is only accessed once during a lookup") 119 | .equals(called, 1); 120 | 121 | called = 0; 122 | observable.subscribe({ 123 | next() { called++ }, 124 | get error() { 125 | called++; 126 | observer.next(); 127 | return function() {}; 128 | } 129 | }); 130 | observer.error(); 131 | test._("When method lookup occurs, subscription is closed") 132 | .equals(called, 1); 133 | 134 | }, 135 | 136 | "Cleanup functions" (test, { Observable }) { 137 | 138 | let called, observer; 139 | 140 | let observable = new Observable(x => { 141 | observer = x; 142 | return _=> { called++ }; 143 | }); 144 | 145 | called = 0; 146 | observable.subscribe({}); 147 | try { observer.error() } 148 | catch (x) {} 149 | test._("Cleanup function is called when observer does not have an error method") 150 | .equals(called, 1); 151 | 152 | called = 0; 153 | observable.subscribe({ error() { return 1 } }); 154 | observer.error(); 155 | test._("Cleanup function is called when observer has an error method") 156 | .equals(called, 1); 157 | 158 | called = 0; 159 | observable.subscribe({ get error() { throw new Error() } }); 160 | observer.error() 161 | test._("Cleanup function is called when method lookup throws") 162 | .equals(called, 1); 163 | 164 | called = 0; 165 | observable.subscribe({ error() { throw new Error() } }); 166 | observer.error() 167 | test._("Cleanup function is called when method throws") 168 | .equals(called, 1); 169 | }, 170 | 171 | }; 172 | -------------------------------------------------------------------------------- /spec/prototype-properties.html: -------------------------------------------------------------------------------- 1 | 2 |

Observable.prototype.subscribe ( _observer_ )

3 | 4 |

The `subscribe` function begins sending values to the supplied _observer_ object by executing the Observable object's subscriber function. It returns a `Subscription` object which may be used to cancel the subscription.

5 | 6 |

The `subscribe` function performs the following steps:

7 | 8 | 9 | 1. Let _O_ be the *this* value. 10 | 1. If Type(_O_) is not Object, throw a *TypeError* exception. 11 | 1. If _O_ does not have an [[Subscriber]] internal slot, throw a *TypeError* exception. 12 | 1. If IsCallable(_observer_) is *true*, then 13 | 1. Let _len_ be the actual number of arguments passed to this function. 14 | 1. Let _args_ be the List of arguments passed to this function. 15 | 1. Let _nextCallback_ be _observer_. 16 | 1. If _len_ > 1, let _errorCallback_ be _args_[1]. 17 | 1. Else, let _errorCallback_ be *undefined*. 18 | 1. If _len_ > 2, let _completeCallback_ be _args_[2]. 19 | 1. Else, let _completeCallback_ be *undefined*. 20 | 1. Let _observer_ be ObjectCreate(%ObjectPrototype%). 21 | 1. Perform ! CreateDataProperty(_observer_, `"next"`, _nextCallback_). 22 | 1. Perform ! CreateDataProperty(_observer_, `"error"`, _errorCallback_). 23 | 1. Perform ! CreateDataProperty(_observer_, `"complete"`, _completeCallback_). 24 | 1. Else if Type(_observer_) is not Object, Let _observer_ be ObjectCreate(%ObjectPrototype%). 25 | 1. Let _subscription_ be ? CreateSubscription(_observer_). 26 | 1. Let _startMethodResult_ be GetMethod(_observer_, `"start"`). 27 | 1. If _startMethodResult_.[[Type]] is ~normal~, then 28 | 1. Let _start_ be _startMethodResult_.[[Value]]. 29 | 1. If _start_ is not *undefined*, then 30 | 1. Let _result_ be Call(_start_, _observer_, « _subscription_ »). 31 | 1. If _result_ is an abrupt completion, perform HostReportErrors(« _result_.[[Value]] »). 32 | 1. If SubscriptionClosed(_subscription_) is *true*, then 33 | 1. Return _subscription_. 34 | 1. Else if _startMethodResult_.[[Type]] is ~throw~, then perform HostReportErrors(« _startMethodResult_.[[Value]] »). 35 | 1. If _result_ is an abrupt completion, perform HostReportErrors(« _result_.[[Value]] »). 36 | 1. Let _subscriptionObserver_ be ? CreateSubscriptionObserver(_subscription_). 37 | 1. Let _subscriber_ be the value of _O's_ [[Subscriber]] internal slot. 38 | 1. Assert: IsCallable(_subscriber_) is *true*. 39 | 1. Let _subscriberResult_ be ExecuteSubscriber(_subscriber_, _subscriptionObserver_). 40 | 1. If _subscriberResult_ is an abrupt completion, then 41 | 1. Perform ! Invoke(_subscriptionObserver_, `"error"`, « ‍_subscriberResult_.[[value]] »). 42 | 1. Else, 43 | 1. Set the [[Cleanup]] internal slot of _subscription_ to _subscriberResult_.[[value]]. 44 | 1. If SubscriptionClosed(_subscription_) is *true*, then 45 | 1. Perform ! CleanupSubscription(_subscription_). 46 | 1. Return _subscription_. 47 | 48 | 49 | 50 |

ExecuteSubscriber ( _subscriber_, _observer_ )

51 | 52 |

The abstract operation ExecuteSubscriber with arguments _subscriber_ and _observer_ performs the following steps:

53 | 54 | 55 | 1. Assert: IsCallable(_subscriber_) is *true*. 56 | 1. Assert: Type(_observer_) is Object. 57 | 1. Let _subscriberResult_ be ? Call(_subscriber_, *undefined*, _observer_). 58 | 1. If _subscriberResult_ is *null* or *undefined*, return *undefined*. 59 | 1. If IsCallable(_subscriberResult_) is *true*, return _subscriberResult_. 60 | 1. Let _result_ be ? GetMethod(_subscriberResult_, `"unsubscribe"`). 61 | 1. If _result_ is *undefined*, throw a *TypeError* exception. 62 | 1. Let _cleanupFunction_ be a new built-in function object as defined in Subscription Cleanup Functions. 63 | 1. Set _cleanupFunction_'s [[Subscription]] internal slot to _subscriberResult_. 64 | 1. Return _cleanupFunction_. 65 | 66 |
67 | 68 | 69 |

Subscription Cleanup Functions

70 | 71 |

A subscription cleanup function is an anonymous built-in function that has a [[Subscription]] internal slot.

72 | 73 |

When a subscription cleanup function _F_ is called the following steps are taken:

74 | 75 | 76 | 1. Assert: _F_ as a [[Subscription]] internal slot whose value is an Object. 77 | 1. Let _subscription_ be the value of _F_'s [[Subscription]] internal slot. 78 | 1. Return Invoke(_subscription_, `"unsubscribe"`, « ‍»). 79 | 80 | 81 |

The `length` property of a subscription cleanup function is `0`.

82 |
83 | 84 |
85 | 86 | 87 |

Observable.prototype.constructor

88 | 89 |

The initial value of `Observable.prototype.constructor` is the intrinsic object %Observable%.

90 |
91 | 92 | 93 |

Observable.prototype [ @@observable ] ( )

94 | 95 |

The following steps are taken:

96 | 97 | 98 | 1. Return the *this* value. 99 | 100 | 101 |

The value of the `name` property of this function is `"[Symbol.observable]"`.

102 |
103 | -------------------------------------------------------------------------------- /test/from.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty, hasSymbol, getSymbol } from "./helpers.js"; 2 | 3 | // TODO: Verify that Observable.from subscriber returns a cleanup function 4 | 5 | export default { 6 | 7 | "Observable has a from property" (test, { Observable }) { 8 | 9 | testMethodProperty(test, Observable, "from", { 10 | configurable: true, 11 | writable: true, 12 | length: 1 13 | }); 14 | }, 15 | 16 | "Allowed argument types" (test, { Observable }) { 17 | 18 | test 19 | ._("Null is not allowed") 20 | .throws(_=> Observable.from(null), TypeError) 21 | ._("Undefined is not allowed") 22 | .throws(_=> Observable.from(undefined), TypeError) 23 | .throws(_=> Observable.from(), TypeError); 24 | }, 25 | 26 | "Uses the this value if it's a function" (test, { Observable }) { 27 | 28 | let usesThis = false; 29 | 30 | Observable.from.call(function(_) { usesThis = true; }, []); 31 | test._("Observable.from will use the 'this' value if it is callable") 32 | .equals(usesThis, true); 33 | }, 34 | 35 | "Uses 'Observable' if the 'this' value is not a function" (test, { Observable }) { 36 | 37 | let result = Observable.from.call({}, []); 38 | 39 | test._("Observable.from will use 'Observable' if the this value is not callable") 40 | .assert(result instanceof Observable); 41 | }, 42 | 43 | "Symbol.observable method is accessed" (test, { Observable }) { 44 | 45 | let called = 0; 46 | 47 | Observable.from({ 48 | get [getSymbol("observable")]() { 49 | called++; 50 | return _=> ({}); 51 | } 52 | }); 53 | 54 | test._("Symbol.observable property is accessed once") 55 | .equals(called, 1); 56 | 57 | test 58 | ._("Symbol.observable must be a function") 59 | .throws(_=> Observable.from({ [getSymbol("observable")]: {} }), TypeError) 60 | .throws(_=> Observable.from({ [getSymbol("observable")]: 0 }), TypeError) 61 | .throws(_=> Observable.from({ [getSymbol("observable")]: null }), TypeError) 62 | .throws(_=> Observable.from({ [getSymbol("observable")]: undefined }), TypeError) 63 | ; 64 | 65 | called = 0; 66 | Observable.from({ 67 | [getSymbol("observable")]() { 68 | called++; 69 | return {}; 70 | } 71 | }); 72 | 73 | test._("Calls the Symbol.observable method") 74 | .equals(called, 1); 75 | }, 76 | 77 | "Return value of Symbol.observable" (test, { Observable }) { 78 | 79 | test._("Throws if the return value of Symbol.observable is not an object") 80 | .throws(_=> Observable.from({ [getSymbol("observable")]() { return 0 } }), TypeError) 81 | .throws(_=> Observable.from({ [getSymbol("observable")]() { return null } }), TypeError) 82 | .throws(_=> Observable.from({ [getSymbol("observable")]() {} }), TypeError) 83 | .not().throws(_=> Observable.from({ [getSymbol("observable")]() { return {} } })) 84 | .not().throws(_=> Observable.from({ get [getSymbol("observable")]() { return _=> ({}) } })) 85 | ; 86 | 87 | let target = function() {}, 88 | returnValue = { constructor: target }; 89 | 90 | let result = Observable.from.call(target, { 91 | [getSymbol("observable")]() { return returnValue } 92 | }); 93 | 94 | test._("Returns the result of Symbol.observable if the object's constructor property " + 95 | "is the target") 96 | .equals(result, returnValue); 97 | 98 | let input = null, 99 | token = {}; 100 | 101 | target = function(fn) { 102 | this.fn = fn; 103 | this.token = token; 104 | }; 105 | 106 | result = Observable.from.call(target, { 107 | [getSymbol("observable")]() { 108 | return { 109 | subscribe(x) { 110 | input = x; 111 | return token; 112 | }, 113 | }; 114 | } 115 | }); 116 | 117 | test._("Calls the constructor if returned object does not have matching constructor " + 118 | "property") 119 | .equals(result.token, token) 120 | ._("Constructor is called with a function") 121 | .equals(typeof result.fn, "function") 122 | ._("Calling the function calls subscribe on the object and returns the result") 123 | .equals(result.fn && result.fn(123), token) 124 | ._("The subscriber argument is supplied to the subscribe method") 125 | .equals(input, 123) 126 | ; 127 | 128 | }, 129 | 130 | "Iterables: values are delivered to next" (test, { Observable }) { 131 | 132 | let values = [], 133 | turns = 0, 134 | iterable = [1, 2, 3, 4]; 135 | 136 | if (hasSymbol("iterator")) 137 | iterable = iterable[Symbol.iterator](); 138 | 139 | Observable.from(iterable).subscribe({ 140 | 141 | next(v) { 142 | values.push(v); 143 | }, 144 | 145 | complete() { 146 | test._("All items are delivered and complete is called") 147 | .equals(values, [1, 2, 3, 4]); 148 | }, 149 | }); 150 | }, 151 | 152 | "Non-convertibles throw" (test, { Observable }) { 153 | 154 | test._("If argument is not observable or iterable, subscribe throws") 155 | .throws(_=> Observable.from({}).subscribe({}), TypeError); 156 | 157 | }, 158 | 159 | }; 160 | -------------------------------------------------------------------------------- /spec/subscription-observer.html: -------------------------------------------------------------------------------- 1 | 2 |

Subscription Observer Abstract Operations

3 | 4 | 5 |

CreateSubscriptionObserver ( _subscription_ )

6 | 7 |

The abstract operation CreateSubscriptionObserver with argument _observer_ is used to create a normalized observer which can be supplied to an observable's subscriber function. It performs the following steps:

8 | 9 | 10 | 1. Assert: Type(_subscription_) is Object. 11 | 1. Let _subscriptionObserver_ be ObjectCreate(%SubscriptionObserverPrototype%, «‍ [[Subscription]] »). 12 | 1. Set _subscriptionObserver_'s [[Subscription]] internal slot to _subscription_. 13 | 1. Return _subscriptionObserver_. 14 | 15 |
16 |
17 | 18 | 19 |

The %SubscriptionObserverPrototype% Object

20 | 21 |

All Subscription Observer objects inherit properties from the %SubscriptionObserverPrototype% intrinsic object. The %SubscriptionObserverPrototype% object is an ordinary object and its [[Prototype]] internal slot is the %ObjectPrototype% intrinsic object. In addition, %SubscriptionObserverPrototype% has the following properties:

22 | 23 | 24 |

get %SubscriptionObserverPrototype%.closed

25 | 26 | 1. Let _O_ be the *this* value. 27 | 1. If Type(_O_) is not Object, throw a *TypeError* exception. 28 | 1. If _O_ does not have all of the internal slots of a Subscription Observer instance, throw a *TypeError* exception. 29 | 1. Let _subscription_ be the value of _O_'s [[Subscription]] internal. 30 | 1. Return ! SubscriptionClosed(_subscription_). 31 | 32 |
33 | 34 | 35 |

%SubscriptionObserverPrototype%.next ( _value_ )

36 | 37 | 1. Let _O_ be the *this* value. 38 | 1. If Type(_O_) is not Object, throw a *TypeError* exception. 39 | 1. If _O_ does not have all of the internal slots of a Subscription Observer instance, throw a *TypeError* exception. 40 | 1. Let _subscription_ be the value of _O_'s [[Subscription]] internal slot. 41 | 1. If SubscriptionClosed(_subscription_) is *true*, return *undefined*. 42 | 1. Let _observer_ be the value of _subscription_'s [[Observer]] internal slot. 43 | 1. Assert: Type(_observer_) is Object. 44 | 1. Let _nextMethodResult_ be GetMethod(_observer_, `"next"`). 45 | 1. If _nextMethodResult_.[[Type]] is ~normal~, then 46 | 1. Let _nextMethod_ be _nextMethodResult_.[[Value]]. 47 | 1. If _nextMethod_ is not *undefined*, then 48 | 1. Let _result_ be Call(_nextMethod_, _observer_, « ‍_value_ »). 49 | 1. If _result_ is an abrupt completion, perform HostReportErrors(« _result_.[[Value]] »). 50 | 1. Else if _nextMethodResult_.[[Type]] is ~throw~, then perform HostReportErrors(« _nextMethodResult_.[[Value]] »). 51 | 1. Return *undefined*. 52 | 53 |
54 | 55 | 56 |

%SubscriptionObserverPrototype%.error ( _exception_ )

57 | 58 | 1. Let _O_ be the *this* value. 59 | 1. If Type(_O_) is not Object, throw a *TypeError* exception. 60 | 1. If _O_ does not have all of the internal slots of a Subscription Observer instance, throw a *TypeError* exception. 61 | 1. Let _subscription_ be the value of _O_'s [[Subscription]] internal slot. 62 | 1. If SubscriptionClosed(_subscription_) is *true*, return *undefined*. 63 | 1. Let _observer_ be the value of _subscription_'s [[Observer]] internal slot. 64 | 1. Set _subscription_'s [[Observer]] internal slot to *undefined*. 65 | 1. Assert: Type(_observer_) is Object. 66 | 1. Let _errorMethodResult_ be GetMethod(_observer_, `"error"`). 67 | 1. If _errorMethodResult_.[[Type]] is ~normal~, then 68 | 1. Let _errorMethod_ be _errorMethodResult_.[[Value]]. 69 | 1. If _errorMethod_ is not *undefined*, then 70 | 1. Let _result_ be Call(_errorMethod_, _observer_, « _exception_ »). 71 | 1. If _result_ is an abrupt completion, perform HostReportErrors(« _result_.[[Value]] »). 72 | 1. Else if _errorMethodResult_.[[Type]] is ~throw~, then perform HostReportErrors(« _errorMethodResult_.[[Value]] »). 73 | 1. Perform ! CleanupSubscription(_subscription_). 74 | 1. Return *undefined*. 75 | 76 |
77 | 78 | 79 |

%SubscriptionObserverPrototype%.complete ( )

80 | 81 | 1. Let _O_ be the *this* value. 82 | 1. If Type(_O_) is not Object, throw a *TypeError* exception. 83 | 1. If _O_ does not have all of the internal slots of a Subscription Observer instance, throw a *TypeError* exception. 84 | 1. Let _subscription_ be the value of _O_'s [[Subscription]] internal slot. 85 | 1. If SubscriptionClosed(_subscription_) is *true*, return *undefined*. 86 | 1. Let _observer_ be the value of _subscription_'s [[Observer]] internal slot. 87 | 1. Set _subscription_'s [[Observer]] internal slot to *undefined*. 88 | 1. Assert: Type(_observer_) is Object. 89 | 1. Let _completeMethodResult_ be GetMethod(_observer_, `"complete"`). 90 | 1. If _completeMethodResult_.[[Type]] is ~normal~, then 91 | 1. Let _completeMethod_ be _completeMethodResult_.[[Value]]. 92 | 1. If _completeMethod_ is not *undefined*, then 93 | 1. Let _result_ be Call(_completeMethod_, _observer_). 94 | 1. If _result_ is an abrupt completion, perform HostReportErrors(« _result_.[[Value]] »). 95 | 1. Else if _completeMethodResult_.[[Type]] is ~throw~, then perform HostReportErrors(« _completeMethodResult_.[[Value]] »). 96 | 1. Perform ! CleanupSubscription(_subscription_). 97 | 1. Return *undefined*. 98 | 99 |
100 | 101 |
102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ECMAScript Observable ## 2 | 3 | This proposal introduces an **Observable** type to the ECMAScript standard library. 4 | The **Observable** type can be used to model push-based data sources such as DOM 5 | events, timer intervals, and sockets. In addition, observables are: 6 | 7 | - *Compositional*: Observables can be composed with higher-order combinators. 8 | - *Lazy*: Observables do not start emitting data until an **observer** has subscribed. 9 | 10 | ### Example: Observing Keyboard Events ### 11 | 12 | Using the **Observable** constructor, we can create a function which returns an 13 | observable stream of events for an arbitrary DOM element and event type. 14 | 15 | ```js 16 | function listen(element, eventName) { 17 | return new Observable(observer => { 18 | // Create an event handler which sends data to the sink 19 | let handler = event => observer.next(event); 20 | 21 | // Attach the event handler 22 | element.addEventListener(eventName, handler, true); 23 | 24 | // Return a cleanup function which will cancel the event stream 25 | return () => { 26 | // Detach the event handler from the element 27 | element.removeEventListener(eventName, handler, true); 28 | }; 29 | }); 30 | } 31 | ``` 32 | 33 | We can then use standard combinators to filter and map the events in the stream, 34 | just like we would with an array. 35 | 36 | ```js 37 | // Return an observable of special key down commands 38 | function commandKeys(element) { 39 | let keyCommands = { "38": "up", "40": "down" }; 40 | 41 | return listen(element, "keydown") 42 | .filter(event => event.keyCode in keyCommands) 43 | .map(event => keyCommands[event.keyCode]) 44 | } 45 | ``` 46 | 47 | *Note: The "filter" and "map" methods are not included in this proposal. They may 48 | be added in a future version of this specification.* 49 | 50 | When we want to consume the event stream, we subscribe with an **observer**. 51 | 52 | ```js 53 | let subscription = commandKeys(inputElement).subscribe({ 54 | next(val) { console.log("Received key command: " + val) }, 55 | error(err) { console.log("Received an error: " + err) }, 56 | complete() { console.log("Stream complete") }, 57 | }); 58 | ``` 59 | 60 | The object returned by **subscribe** will allow us to cancel the subscription at any time. 61 | Upon cancelation, the Observable's cleanup function will be executed. 62 | 63 | ```js 64 | // After calling this function, no more events will be sent 65 | subscription.unsubscribe(); 66 | ``` 67 | 68 | ### Motivation ### 69 | 70 | The Observable type represents one of the fundamental protocols for processing asynchronous 71 | streams of data. It is particularly effective at modeling streams of data which originate 72 | from the environment and are pushed into the application, such as user interface events. By 73 | offering Observable as a component of the ECMAScript standard library, we allow platforms 74 | and applications to share a common push-based stream protocol. 75 | 76 | ### Implementations ### 77 | 78 | - [RxJS 5](https://github.com/ReactiveX/RxJS) 79 | - [core-js](https://github.com/zloirock/core-js#observable) 80 | - [zen-observable](https://github.com/zenparsing/zen-observable) 81 | - [fate-observable](https://github.com/shanewholloway/node-fate-observable) 82 | 83 | ### Running Tests ### 84 | 85 | To run the unit tests, install the **es-observable-tests** package into your project. 86 | 87 | ``` 88 | npm install es-observable-tests 89 | ``` 90 | 91 | Then call the exported `runTests` function with the constructor you want to test. 92 | 93 | ```js 94 | require("es-observable-tests").runTests(MyObservable); 95 | ``` 96 | 97 | ### API ### 98 | 99 | #### Observable #### 100 | 101 | An Observable represents a sequence of values which may be observed. 102 | 103 | ```js 104 | interface Observable { 105 | 106 | constructor(subscriber : SubscriberFunction); 107 | 108 | // Subscribes to the sequence with an observer 109 | subscribe(observer : Observer) : Subscription; 110 | 111 | // Subscribes to the sequence with callbacks 112 | subscribe(onNext : Function, 113 | onError? : Function, 114 | onComplete? : Function) : Subscription; 115 | 116 | // Returns itself 117 | [Symbol.observable]() : Observable; 118 | 119 | // Converts items to an Observable 120 | static of(...items) : Observable; 121 | 122 | // Converts an observable or iterable to an Observable 123 | static from(observable) : Observable; 124 | 125 | } 126 | 127 | interface Subscription { 128 | 129 | // Cancels the subscription 130 | unsubscribe() : void; 131 | 132 | // A boolean value indicating whether the subscription is closed 133 | get closed() : Boolean; 134 | } 135 | 136 | function SubscriberFunction(observer: SubscriptionObserver) : (void => void)|Subscription; 137 | ``` 138 | 139 | #### Observable.of #### 140 | 141 | `Observable.of` creates an Observable of the values provided as arguments. The values 142 | are delivered synchronously when `subscribe` is called. 143 | 144 | ```js 145 | Observable.of("red", "green", "blue").subscribe({ 146 | next(color) { 147 | console.log(color); 148 | } 149 | }); 150 | 151 | /* 152 | > "red" 153 | > "green" 154 | > "blue" 155 | */ 156 | ``` 157 | 158 | #### Observable.from #### 159 | 160 | `Observable.from` converts its argument to an Observable. 161 | 162 | - If the argument has a `Symbol.observable` method, then it returns the result of 163 | invoking that method. If the resulting object is not an instance of Observable, 164 | then it is wrapped in an Observable which will delegate subscription. 165 | - Otherwise, the argument is assumed to be an iterable and the iteration values are 166 | delivered synchronously when `subscribe` is called. 167 | 168 | Converting from an object which supports `Symbol.observable` to an Observable: 169 | 170 | ```js 171 | Observable.from({ 172 | [Symbol.observable]() { 173 | return new Observable(observer => { 174 | setTimeout(() => { 175 | observer.next("hello"); 176 | observer.next("world"); 177 | observer.complete(); 178 | }, 2000); 179 | }); 180 | } 181 | }).subscribe({ 182 | next(value) { 183 | console.log(value); 184 | } 185 | }); 186 | 187 | /* 188 | > "hello" 189 | > "world" 190 | */ 191 | 192 | let observable = new Observable(observer => {}); 193 | Observable.from(observable) === observable; // true 194 | 195 | ``` 196 | 197 | Converting from an iterable to an Observable: 198 | 199 | ```js 200 | Observable.from(["mercury", "venus", "earth"]).subscribe({ 201 | next(value) { 202 | console.log(value); 203 | } 204 | }); 205 | 206 | /* 207 | > "mercury" 208 | > "venus" 209 | > "earth" 210 | */ 211 | ``` 212 | 213 | #### Observer #### 214 | 215 | An Observer is used to receive data from an Observable, and is supplied as an 216 | argument to **subscribe**. 217 | 218 | All methods are optional. 219 | 220 | ```js 221 | interface Observer { 222 | 223 | // Receives the subscription object when `subscribe` is called 224 | start(subscription : Subscription); 225 | 226 | // Receives the next value in the sequence 227 | next(value); 228 | 229 | // Receives the sequence error 230 | error(errorValue); 231 | 232 | // Receives a completion notification 233 | complete(); 234 | } 235 | ``` 236 | 237 | #### SubscriptionObserver #### 238 | 239 | A SubscriptionObserver is a normalized Observer which wraps the observer object supplied to 240 | **subscribe**. 241 | 242 | ```js 243 | interface SubscriptionObserver { 244 | 245 | // Sends the next value in the sequence 246 | next(value); 247 | 248 | // Sends the sequence error 249 | error(errorValue); 250 | 251 | // Sends the completion notification 252 | complete(); 253 | 254 | // A boolean value indicating whether the subscription is closed 255 | get closed() : Boolean; 256 | } 257 | ``` 258 | -------------------------------------------------------------------------------- /src/Observable.js: -------------------------------------------------------------------------------- 1 | // === Symbol Polyfills === 2 | 3 | function polyfillSymbol(name) { 4 | 5 | if (!Symbol[name]) 6 | Object.defineProperty(Symbol, name, { value: Symbol(name) }); 7 | } 8 | 9 | polyfillSymbol("observable"); 10 | 11 | // === Abstract Operations === 12 | 13 | function nonEnum(obj) { 14 | 15 | Object.getOwnPropertyNames(obj).forEach(k => { 16 | Object.defineProperty(obj, k, { enumerable: false }); 17 | }); 18 | 19 | return obj; 20 | } 21 | 22 | function getMethod(obj, key) { 23 | 24 | let value = obj[key]; 25 | 26 | if (value == null) 27 | return undefined; 28 | 29 | if (typeof value !== "function") 30 | throw new TypeError(value + " is not a function"); 31 | 32 | return value; 33 | } 34 | 35 | function cleanupSubscription(subscription) { 36 | 37 | // Assert: observer._observer is undefined 38 | 39 | let cleanup = subscription._cleanup; 40 | 41 | if (!cleanup) 42 | return; 43 | 44 | // Drop the reference to the cleanup function so that we won't call it 45 | // more than once 46 | subscription._cleanup = undefined; 47 | 48 | // Call the cleanup function 49 | try { 50 | cleanup(); 51 | } 52 | catch(e) { 53 | // HostReportErrors(e); 54 | } 55 | } 56 | 57 | function subscriptionClosed(subscription) { 58 | 59 | return subscription._observer === undefined; 60 | } 61 | 62 | function closeSubscription(subscription) { 63 | 64 | if (subscriptionClosed(subscription)) 65 | return; 66 | 67 | subscription._observer = undefined; 68 | cleanupSubscription(subscription); 69 | } 70 | 71 | function cleanupFromSubscription(subscription) { 72 | return _=> { subscription.unsubscribe() }; 73 | } 74 | 75 | function Subscription(observer, subscriber) { 76 | // Assert: subscriber is callable 77 | // The observer must be an object 78 | this._cleanup = undefined; 79 | this._observer = observer; 80 | 81 | // If the observer has a start method, call it with the subscription object 82 | try { 83 | let start = getMethod(observer, "start"); 84 | 85 | if (start) { 86 | start.call(observer, this); 87 | } 88 | } 89 | catch(e) { 90 | // HostReportErrors(e); 91 | } 92 | 93 | // If the observer has unsubscribed from the start method, exit 94 | if (subscriptionClosed(this)) 95 | return; 96 | 97 | observer = new SubscriptionObserver(this); 98 | 99 | try { 100 | 101 | // Call the subscriber function 102 | let cleanup = subscriber.call(undefined, observer); 103 | 104 | // The return value must be undefined, null, a subscription object, or a function 105 | if (cleanup != null) { 106 | if (typeof cleanup.unsubscribe === "function") 107 | cleanup = cleanupFromSubscription(cleanup); 108 | else if (typeof cleanup !== "function") 109 | throw new TypeError(cleanup + " is not a function"); 110 | 111 | this._cleanup = cleanup; 112 | } 113 | 114 | } catch (e) { 115 | 116 | // If an error occurs during startup, then send the error 117 | // to the observer. 118 | observer.error(e); 119 | return; 120 | } 121 | 122 | // If the stream is already finished, then perform cleanup 123 | if (subscriptionClosed(this)) { 124 | cleanupSubscription(this); 125 | } 126 | } 127 | 128 | Subscription.prototype = nonEnum({ 129 | get closed() { return subscriptionClosed(this) }, 130 | unsubscribe() { closeSubscription(this) }, 131 | }); 132 | 133 | function SubscriptionObserver(subscription) { 134 | this._subscription = subscription; 135 | } 136 | 137 | SubscriptionObserver.prototype = nonEnum({ 138 | 139 | get closed() { 140 | 141 | return subscriptionClosed(this._subscription); 142 | }, 143 | 144 | next(value) { 145 | 146 | let subscription = this._subscription; 147 | 148 | // If the stream if closed, then return undefined 149 | if (subscriptionClosed(subscription)) 150 | return undefined; 151 | 152 | let observer = subscription._observer; 153 | 154 | try { 155 | let m = getMethod(observer, "next"); 156 | 157 | // If the observer doesn't support "next", then return undefined 158 | if (!m) 159 | return undefined; 160 | 161 | // Send the next value to the sink 162 | m.call(observer, value); 163 | } 164 | catch(e) { 165 | // HostReportErrors(e); 166 | } 167 | return undefined; 168 | }, 169 | 170 | error(value) { 171 | 172 | let subscription = this._subscription; 173 | 174 | // If the stream is closed, throw the error to the caller 175 | if (subscriptionClosed(subscription)) { 176 | return undefined; 177 | } 178 | 179 | let observer = subscription._observer; 180 | subscription._observer = undefined; 181 | 182 | try { 183 | 184 | let m = getMethod(observer, "error"); 185 | 186 | // If the sink does not support "complete", then return undefined 187 | if (m) { 188 | m.call(observer, value); 189 | } 190 | else { 191 | // HostReportErrors(e); 192 | } 193 | } catch (e) { 194 | // HostReportErrors(e); 195 | } 196 | 197 | cleanupSubscription(subscription); 198 | 199 | return undefined; 200 | }, 201 | 202 | complete() { 203 | 204 | let subscription = this._subscription; 205 | 206 | // If the stream is closed, then return undefined 207 | if (subscriptionClosed(subscription)) 208 | return undefined; 209 | 210 | let observer = subscription._observer; 211 | subscription._observer = undefined; 212 | 213 | try { 214 | 215 | let m = getMethod(observer, "complete"); 216 | 217 | // If the sink does not support "complete", then return undefined 218 | if (m) { 219 | m.call(observer); 220 | } 221 | } catch (e) { 222 | // HostReportErrors(e); 223 | } 224 | 225 | cleanupSubscription(subscription); 226 | 227 | return undefined; 228 | }, 229 | 230 | }); 231 | 232 | export class Observable { 233 | 234 | // == Fundamental == 235 | 236 | constructor(subscriber) { 237 | 238 | // The stream subscriber must be a function 239 | if (typeof subscriber !== "function") 240 | throw new 241 | TypeError("Observable initializer must be a function"); 242 | 243 | this._subscriber = subscriber; 244 | } 245 | 246 | subscribe(observer, ...args) { 247 | if (typeof observer === "function") { 248 | observer = { 249 | next: observer, 250 | error: args[0], 251 | complete: args[1] 252 | }; 253 | } 254 | else if (typeof observer !== "object") { 255 | observer = {}; 256 | } 257 | 258 | return new Subscription(observer, this._subscriber); 259 | } 260 | 261 | [Symbol.observable]() { return this } 262 | 263 | // == Derived == 264 | 265 | static from(x) { 266 | 267 | let C = typeof this === "function" ? this : Observable; 268 | 269 | if (x == null) 270 | throw new TypeError(x + " is not an object"); 271 | 272 | let method = getMethod(x, Symbol.observable); 273 | 274 | if (method) { 275 | 276 | let observable = method.call(x); 277 | 278 | if (Object(observable) !== observable) 279 | throw new TypeError(observable + " is not an object"); 280 | 281 | if (observable.constructor === C) 282 | return observable; 283 | 284 | return new C(observer => observable.subscribe(observer)); 285 | } 286 | 287 | method = getMethod(x, Symbol.iterator); 288 | 289 | if (!method) 290 | throw new TypeError(x + " is not observable"); 291 | 292 | return new C(observer => { 293 | 294 | for (let item of method.call(x)) { 295 | 296 | observer.next(item); 297 | 298 | if (observer.closed) 299 | return; 300 | } 301 | 302 | observer.complete(); 303 | }); 304 | } 305 | 306 | static of(...items) { 307 | 308 | let C = typeof this === "function" ? this : Observable; 309 | 310 | return new C(observer => { 311 | 312 | for (let i = 0; i < items.length; ++i) { 313 | 314 | observer.next(items[i]); 315 | 316 | if (observer.closed) 317 | return; 318 | } 319 | 320 | observer.complete(); 321 | }); 322 | } 323 | 324 | } 325 | -------------------------------------------------------------------------------- /test/subscribe.js: -------------------------------------------------------------------------------- 1 | import { testMethodProperty } from "./helpers.js"; 2 | 3 | export default { 4 | 5 | "Observable.prototype has a subscribe property" (test, { Observable }) { 6 | 7 | testMethodProperty(test, Observable.prototype, "subscribe", { 8 | configurable: true, 9 | writable: true, 10 | length: 1, 11 | }); 12 | }, 13 | 14 | "Argument type" (test, { Observable }) { 15 | 16 | let x = new Observable(sink => null); 17 | 18 | test 19 | ._("Any value passed as observer will not cause subscribe to throw") 20 | .not().throws(_=> x.subscribe(null)) 21 | .not().throws(_=> x.subscribe(undefined)) 22 | .not().throws(_=> x.subscribe(1)) 23 | .not().throws(_=> x.subscribe(true)) 24 | .not().throws(_=> x.subscribe("string")) 25 | .not().throws(_=> x.subscribe({})) 26 | .not().throws(_=> x.subscribe(Object(1))) 27 | .not().throws(_=> x.subscribe(function() {})) 28 | ; 29 | }, 30 | 31 | "Function arguments" (test, { Observable }) { 32 | 33 | let list = [], error = new Error(); 34 | 35 | new Observable(s => { 36 | s.next(1); 37 | s.error(error); 38 | }).subscribe( 39 | x => list.push("next:" + x), 40 | e => list.push(e), 41 | x => list.push("complete:" + x) 42 | ); 43 | 44 | new Observable(s => { 45 | s.complete(); 46 | }).subscribe( 47 | x => list.push("next:" + x), 48 | e => list.push(e), 49 | x => list.push("complete") 50 | ); 51 | 52 | test 53 | ._("First argument is next callback") 54 | .equals(list[0], "next:1") 55 | ._("Second argument is error callback") 56 | .equals(list[1], error) 57 | ._("Third argument is complete callback") 58 | .equals(list[2], "complete"); 59 | 60 | list = []; 61 | 62 | test. 63 | _("Second and third arguments are optional") 64 | .not().throws( 65 | () => new Observable( 66 | s => { 67 | s.next(1); 68 | s.complete(); 69 | }).subscribe(x => list.push("next:" + x))) 70 | .equals(list, ["next:1"]); 71 | }, 72 | 73 | "Subscriber arguments" (test, { Observable }) { 74 | 75 | let observer = null; 76 | new Observable(x => { observer = x }).subscribe({}); 77 | 78 | test._("Subscriber is called with an observer") 79 | .equals(typeof observer, "object") 80 | .equals(typeof observer.next, "function") 81 | .equals(typeof observer.error, "function") 82 | .equals(typeof observer.complete, "function") 83 | ; 84 | 85 | test._("Subscription observer's constructor property is Object") 86 | .equals(observer.constructor, Object); 87 | }, 88 | 89 | "Subscriber return types" (test, { Observable }) { 90 | 91 | let type = "", sink = {}; 92 | 93 | test 94 | ._("Undefined can be returned") 95 | .not().throws(_=> new Observable(sink => undefined).subscribe(sink)) 96 | ._("Null can be returned") 97 | .not().throws(_=> new Observable(sink => null).subscribe(sink)) 98 | ._("Functions can be returned") 99 | .not().throws(_=> new Observable(sink => function() {}).subscribe(sink)) 100 | ._("Subscriptions can be returned") 101 | .not().throws(_=> new Observable(sink => ({ unsubscribe() {} }).subscribe(sink))) 102 | ._("Non callable, non-subscription objects cannot be returned") 103 | .throws( 104 | _ => { 105 | let error; 106 | new Observable(sink => ({})).subscribe({ error(e) { error = e; } }); 107 | throw error; 108 | }, 109 | TypeError) 110 | ._("Non-functions cannot be returned") 111 | .throws( 112 | _ => { 113 | let error; 114 | new Observable(sink => 0).subscribe({ error(e) { error = e; } }); 115 | throw error; 116 | }, 117 | TypeError) 118 | .throws( 119 | _ => { 120 | let error; 121 | new Observable(sink => false).subscribe({ error(e) { error = e; } }); 122 | throw error; 123 | }, 124 | TypeError); 125 | }, 126 | 127 | "Returns a subscription object" (test, { Observable }) { 128 | 129 | let called = 0; 130 | let subscription = new Observable(observer => { 131 | return _=> called++; 132 | }).subscribe({}); 133 | 134 | let proto = Object.getPrototypeOf(subscription); 135 | 136 | testMethodProperty(test, proto, "unsubscribe", { 137 | configurable: true, 138 | writable: true, 139 | length: 0, 140 | }); 141 | 142 | testMethodProperty(test, proto, "closed", { 143 | get: true, 144 | configurable: true, 145 | writable: true, 146 | length: 0, 147 | }); 148 | 149 | test 150 | ._("Subscribe returns an object") 151 | .equals(typeof subscription, "object") 152 | ._("Contructor property is Object") 153 | .equals(subscription.constructor, Object) 154 | ._("closed property returns false before unsubscription") 155 | .equals(subscription.closed, false) 156 | ._("Unsubscribe returns undefined") 157 | .equals(subscription.unsubscribe(), undefined) 158 | ._("Unsubscribe calls the cleanup function") 159 | .equals(called, 1) 160 | ._("closed property is true after calling unsubscribe") 161 | .equals(subscription.closed, true) 162 | ; 163 | }, 164 | 165 | "Cleanup function" (test, { Observable }) { 166 | 167 | let called = 0, 168 | returned = 0; 169 | 170 | let subscription = new Observable(sink => { 171 | return _=> { called++ }; 172 | }).subscribe({ 173 | complete() { returned++ }, 174 | }); 175 | 176 | subscription.unsubscribe(); 177 | 178 | test._("The cleanup function is called when unsubscribing") 179 | .equals(called, 1); 180 | 181 | subscription.unsubscribe(); 182 | 183 | test._("The cleanup function is not called again when unsubscribe is called again") 184 | .equals(called, 1); 185 | 186 | called = 0; 187 | 188 | new Observable(sink => { 189 | sink.error(1); 190 | return _=> { called++ }; 191 | }).subscribe({ 192 | error() {}, 193 | }); 194 | 195 | test._("The cleanup function is called when an error is sent to the sink") 196 | .equals(called, 1); 197 | 198 | called = 0; 199 | 200 | new Observable(sink => { 201 | sink.complete(1); 202 | return _=> { called++ }; 203 | }).subscribe({ 204 | next() {}, 205 | }); 206 | 207 | test._("The cleanup function is called when a complete is sent to the sink") 208 | .equals(called, 1); 209 | 210 | let unsubscribeArgs = null; 211 | called = 0; 212 | 213 | subscription = new Observable(sink => { 214 | return { 215 | unsubscribe(...args) { 216 | called = 1; 217 | unsubscribeArgs = args; 218 | } 219 | }; 220 | }).subscribe({ 221 | next() {}, 222 | }); 223 | 224 | subscription.unsubscribe(1); 225 | test._("If a subscription is returned, then unsubscribe is called on cleanup") 226 | .equals(called, 1) 227 | ._("Arguments are not forwarded to the unsubscribe function") 228 | .equals(unsubscribeArgs, []); 229 | 230 | }, 231 | 232 | "Exceptions thrown from the subscriber" (test, { Observable }) { 233 | 234 | let error = new Error(), 235 | observable = new Observable(_=> { throw error }); 236 | 237 | test._("Subscribe does not throw if the observer does not handle errors") 238 | .not().throws(_=> observable.subscribe({}), error); 239 | 240 | let thrown = null; 241 | 242 | test._("Subscribe does not throw if the observer has an error method") 243 | .not().throws(_=> { observable.subscribe({ error(e) { thrown = e } }) }); 244 | 245 | test._("Subscribe sends an error to the observer") 246 | .equals(thrown, error); 247 | }, 248 | 249 | "Start method" (test, { Observable }) { 250 | 251 | let events = []; 252 | 253 | let observable = new Observable(observer => { 254 | events.push("subscriber"); 255 | observer.complete(); 256 | }); 257 | 258 | let observer = { 259 | 260 | startCalls: 0, 261 | thisValue: null, 262 | subscription: null, 263 | 264 | start(subscription) { 265 | events.push("start"); 266 | observer.startCalls++; 267 | observer.thisValue = this; 268 | observer.subscription = subscription; 269 | } 270 | } 271 | 272 | let subscription = observable.subscribe(observer); 273 | 274 | test._("If the observer has a start method, it is called") 275 | .equals(observer.startCalls, 1) 276 | ._("Start is called with the observer as the this value") 277 | .equals(observer.thisValue, observer) 278 | ._("Start is called with the subscription as the first argument") 279 | .equals(observer.subscription, subscription) 280 | ._("Start is called before the subscriber function is called") 281 | .equals(events, ["start", "subscriber"]); 282 | 283 | events = []; 284 | 285 | observer = { 286 | start(subscription) { 287 | events.push("start"); 288 | subscription.unsubscribe(); 289 | } 290 | }; 291 | 292 | subscription = observable.subscribe(observer); 293 | 294 | test._("If unsubscribe is called from start, the subscriber is not called") 295 | .equals(events, ["start"]); 296 | }, 297 | 298 | }; 299 | -------------------------------------------------------------------------------- /Why error and complete.md: -------------------------------------------------------------------------------- 1 | Why are error and completion notifications useful in Event Streams? 2 | ====== 3 | 4 | Observables have a well-defined way of notifying that a stream of data has ended, either due to error or completion. These notifications are sent by either resolving or rejecting the Promise returned from `forEach`: 5 | 6 | ```js 7 | let promise = someObservable.forEach(value => process(value)); 8 | promise.then( 9 | result => console.log(“Final value:”, result), 10 | error => console.error(“Oh no!:”, error)); 11 | ``` 12 | 13 | This contrasts with both EventTarget (ET) and EventEmitter (EE), neither of which has a well-defined way of notifying that the stream has ended due to completion or error. It's reasonable to question the value of these notifications given that they are not present in either EE or ET, arguably the two most common push stream APIs used in JavaScript. 14 | 15 | This document attempts to justify the value of completion and error notifications by demonstrating that they enable useful composition operations. These composition operations in turn allow for a wider range of async programming patterns to be expressed within asynchronous functions. This improves developer ergonomics because using async functions offer developers a host of benefits including... 16 | 17 | * Avoiding memory leaks caused by failure to release callbacks 18 | * Automatically propagating errors 19 | * The ability to use JavaScript control flow primitives (for/while/try) 20 | 21 | Enabling Event Stream Composition with Completion notifications 22 | ------ 23 | 24 | On the web it is common to build workflows in which events in an event stream are processed until an event is received from another event stream. Here are some examples: 25 | 26 | * Listening for events from a DOM element until another event occurs which causes the element to be unmounted 27 | * Drawing a signature on a canvas by listening to a mouse move until a mouse up move is received 28 | * Dragging elements in a user-interface across the screen 29 | * Recognizing complex touch gestures 30 | 31 | Listening to an event stream until an event is received from another event stream can be cumbersome using either EE or ET. Neither API returns Promises, which makes it difficult to coordinate their events in async functions. As a consequence developers must often fallback to using callbacks and state machines. 32 | 33 | Here’s an example that draws a signature on a canvas until a cancel button is pressed: 34 | 35 | ```js 36 | function drawSignature(canvas, cancelButton, okButton) { 37 | const context = signatureCanvas.getContext('2d'); 38 | const toPoint = e => ({ x: e.offsetX, y: e.offsetY }); 39 | let onMouseDown, onMouseMove, onMouseUp, onCancelClick; 40 | 41 | onMouseUp = () => { 42 | canvas.removeEventListener('mousemove', onMouseMove); 43 | canvas.removeEventListener('mouseup', onMouseUp); 44 | }; 45 | 46 | onMouseDown = e => { 47 | let lastPoint = toPoint(e); 48 | 49 | onMouseMove = e => { 50 | let point = toPoint(e); 51 | strokeLine(context, lastPoint.x, lastPoint.y, point.x, point.y); 52 | lastPoint = point; 53 | okButton.disabled = false; 54 | }; 55 | 56 | canvas.addEventListener('mousemove', onMouseMove); 57 | canvas.addEventListener('mouseup', onMouseUp); 58 | }; 59 | 60 | onCancelClick = e => { 61 | onmouseup(); 62 | canvas.removeEventListener('mousedown', onMouseDown); 63 | cancelButton.removeEventListener('click', onCancelClick); 64 | }; 65 | 66 | canvas.addEventListener('mousedown', onMouseDown); 67 | cancelButton.addEventListener('click', onCancelClick); 68 | } 69 | ``` 70 | 71 | In addition to the nonlinear nature of the code above, note how easy it is to accidentally omit event unsubscription. Neglecting to unsubscribe from events can cause memory leaks which are difficult to track down and gradually degrade application performance. These leaks are more severe in single-page web applications, because long-running pages are more likely to run out of memory. 72 | 73 | ### Declarative concurrency in async functions using takeUntil 74 | 75 | It’s interesting to note **while most ETs and EEs are infinite, it is possible to build a single *finite* event streams from two or more infinite event streams.** By adding an explicit completion event to Observable we are able to create a very useful composition operation: `takeUntil`. 76 | 77 | The `takeUntil` method operation accepts a “source” Observable, and a "stop" Observable, and concurrently listens to both. The result is a composed Observable that forwards all of the events received from the "source" stream until a notification is received from the "stop" stream. Once a notification is received from the "stop" stream, the composed Observable notifies completion to its Observer, and unsubscribes from both the “source” and “stop” streams. 78 | 79 | Here’s the signature code collection above rewritten using Observable and takeUntil: 80 | 81 | ```js 82 | import { _ } from 'lodash-for-events'; 83 | 84 | async function drawSignature(signatureCanvas, okButton, token) { 85 | await.cancelToken = cancelToken; 86 | const context = signatureCanvas.getContext('2d'); 87 | const toPoint = e => ({ x: e.offsetX, y: e.offsetY }); 88 | const sigMouseDowns = 89 | _(signatureCanvas.on('mousedown')).map(toPoint); 90 | const sigMouseMoves = 91 | _(signatureCanvas.on('mousemove')).map(toPoint); 92 | const sigMouseUps = 93 | _(signatureCanvas.on('mouseup')).map(toPoint); 94 | 95 | while(true) { 96 | let lastPoint = await sigMouseDowns.first(token); 97 | 98 | await sigMouseMoves.takeUntil(sigMouseUps). 99 | forEach( 100 | point => { 101 | strokeLine(context, lastPoint.x, lastPoint.y, point.x, point.y); 102 | okButton.disabled = false; 103 | lastPoint = point; 104 | }, 105 | token); 106 | } 107 | } 108 | ``` 109 | 110 | In the example above the takeUntil operation concurrently listens to both event streams internally, and exposes a single (possibly) finite event stream which can be consumed within an async function. The `takeUntil` function also removes the need for developers to explicitly unsubscribe from events, because unsubscription is automatically executed when the stream terminates. 111 | 112 | Enabling Observable and Promise composition with Error notifications 113 | ------- 114 | 115 | In the previous section it was demonstrated that adding a completion notification to Observable allows multiple infinite event streams to be composed into a (possibly) finite event stream. This allows the common concurrency pattern of processing an event until another event occurs to be expressed within an async function. 116 | 117 | This section will demonstrate that adding an error notification to Observables enables them to be composed together with Promises to create new event streams. In order to compose Promises and Observables, we must adapt Promises into Observables. In order to ensure that errors from Promise rejections are not swallowed, we must add a corresponding error notification to Observables. If we add an error notification to Observable, Observable’s functions can automatically propagate unhandled errors, just as Promise functions do (ex. `then`). The result is that when new event streams are produced by composing Promises and Observables, errors arising from Promises rejections can be caught using try/else within an async function. 118 | 119 | There are many use cases for combining Observables and Promises on the web. One such use case is an auto-complete box which displays searches as the user types. A well-written autocomplete box has the following features: 120 | 121 | * debounces keypresses to avoid flooding the server with requests 122 | * ensures that responses are not handled out of order (ie. displaying results for “a” on top of “ab”) 123 | * retries individual requests for a certain number of times, but give up if an individual request fails more than 3 times and tell the user to come back later. This reduces traffic in the event the server is down, and gives it a chance to recover. 124 | 125 | In the example below we combine an Observable of keypress events with async requests to create a new stream of search results which are never returned out of order. 126 | 127 | ```js 128 | import { _ } from 'lodash-for-events'; 129 | 130 | async function displaySearchResults(input, searchResultsDiv, token) { 131 | try { 132 | await _(input.on('keyup')). 133 | debounce(20). 134 | map(e => input.value). 135 | switchMap(query => 136 | _.fromPromiseFactory(subToken => search(query, subToken))). 137 | forEach( 138 | results => { 139 | searchResultsDiv.innerHTML = 140 | ""; 145 | }, 146 | token); 147 | } 148 | else { 149 | searchResults.innerHTML = "The server is down right now. Come back later and try again."; 150 | } 151 | } 152 | ``` 153 | 154 | Let’s go over the functions used above one by one: 155 | 156 | * debounce. This function debounces an Observable stream given a timespan in milliseconds. 157 | * search. This function returns a Promise which will eventually return the search results for a given query. The function will retry 3 times to compensate for intermittent server failures, then reject if another error is encountered. 158 | * switchMap. This function ensures that results are never returned out of order. The function is called *switchMap* because it *maps* each item in the stream into a new asynchronous operation, creating a nested stream of async operations (in this case an `Observable>>`). Then it flattens the nested stream of async operations into a flat stream of their results by *switching* to the latest async operation whenever it arrives, and unsubscribing from any outstanding async operations if they exist (in this case producing an `Observable>`). 159 | * fromPromiseFactory. This function accepts a factory function which accepts a cancel token and returns a Promise. The result is an Observable which creates a new instance of the Promise for each subscription, and cancels the underlying async operation when the subscription ends. 160 | 161 | The Promise returned by `search` rejects after a certain number of retries. If the Promise eventually rejects, `fromPromiseFactory` propagates the error through via the Observable’s error notification. Having a well-defined semantic for sending errors ensures each subsequent function (ex. `switchMap`, `forEach`) can automatically forward the error, just as Promise functions automatically forward rejection errors. As a result, the developer can use the familiar try/catch mechanism to handle errors that arise from a Promise which has been composed together with an event stream. 162 | 163 | Observable's error and completion notifications improve the expressiveness of async functions 164 | ------ 165 | 166 | While an individual EE’s or ET’s are typically infinite streams, the examples above demonstrate that adding both completion and error notifications to Observable enables new types of composition operations. These composition operations enable both event streams and Promises to be combined together declaratively. The benefit of this approach is that more async programming patterns can be expressed using simple JavaScript control flow primitives within async functions. 167 | -------------------------------------------------------------------------------- /ObservableEventTarget.md: -------------------------------------------------------------------------------- 1 | # Extending EventTarget with Observable 2 | 3 | Currently the web has two primitives with which developers can build concurrent programs: 4 | 5 | 1. EventTarget 6 | 2. Promise 7 | 8 | Unfortunately the inability to compose these two primitives makes it is difficult to coordinate concurrency without the use of shared mutable state. This introduces incidental complexity into web applications, and increases the likelihood of race conditions. 9 | 10 | This proposal aims to enable more composable approaches to concurrency coordination by adding a new interface to the DOM: `ObservableEventTarget`. `ObservableEventTarget` is an interface which extends `EventTarget` with an `on` method. When the `on` method is invoked with a event type argument, an Observable is created. Events of the same type which are subsequently dispatched to the `ObservableEventTarget` are also dispatched to any observers observing the Observable. `Observable`s shares a common subset of `EventTarget` and `Promise` semantics, allowing concurrent programs which use both primitives to be built compositionally. 11 | 12 | ## ObservableEventTarget API 13 | 14 | The `ObservableEventTarget` interface inherits from `EventTarget` and introduces a new method: `on`. The `on` method creates an`Observable` and forwards events dispatched to the `ObservableEventTarget` to the Observers of that Observable. 15 | 16 | ``` 17 | interface Event { /* https://dom.spec.whatwg.org/#event */ } 18 | 19 | dictionary OnOptions { 20 | // listen for an "error" event on the EventTarget, 21 | // and send it to each Observer's error method 22 | boolean receiveError = false; 23 | 24 | // member indicates that the callback will not cancel 25 | // the event by invoking preventDefault(). 26 | boolean passive = false;, 27 | 28 | // handler function which can optionally execute stateful 29 | // actions on the event before the event is dispatched to 30 | // Observers (ex. event.preventDefault()). 31 | EventHandler handler = null; 32 | 33 | // member indicates that the Observable will complete after 34 | // one event is dispatched. 35 | boolean once = false; 36 | } 37 | 38 | interface ObservableEventTarget extends EventTarget { 39 | Observable on(DOMString type, optional (OnOptions or boolean) options); 40 | } 41 | ``` 42 | 43 | Any implementation of `EventTarget` can also implement the `ObservableEventTarget` interface to enable instances to be adaptated to `Observable`s. 44 | 45 | ## Design Considerations 46 | 47 | The semantics of `EventTarget`'s and `Observable`'s subscription APIs overlap cleanly. Both share the following semantics... 48 | 49 | * the ability to synchronously subscribe and unsubscribe from notifications 50 | * the ability to synchronously dispatch notifications 51 | * errors thrown from notification handlers are reported to the host rather than being propagated 52 | 53 | `EventTarget`s have semantics which control the way events are propagated through the DOM. The `on` method accepts an `OnOptions` dictionary object which allow event propagation semantics to be specified when the ObservableEventTarget is adapted to an Observable. The `OnOptions` dictionary extends the DOM's `AddEventListenerOptions` dictionary object and adds two additional fields: 54 | 55 | 1. `receiveError` 56 | 2. `handler` 57 | 58 | ### The `OnOptions` `receiveError` member 59 | 60 | The `receiveError` member specifies whether or not events with type `"error"` should be passed to the `error` method on the Observable's Observers. 61 | 62 | In the example below the `on` method is used to create an `Observable` which dispatches an Image's "load" event to its observers. Setting the `"once"` member of the `OnOptions` dictionary to `true` results in a `complete` notification being dispatched to the observers immediately afterwards. Once an Observer has been dispatched a `complete` notification, it is unsubscribed from the Observable and consequently the `ObservableEventTarget`. 63 | 64 | ```js 65 | const displayImage = document.querySelector("#displayImage"); 66 | 67 | const image = new Image(); 68 | const load = image.on('load', { receiveError: true, once: true }); 69 | image.src = "./possibleImage"; 70 | 71 | load.subscribe({ 72 | next(e) { 73 | displayImage.src = e.target.src; 74 | }, 75 | error(e) { 76 | displayImage.src = "errorloading.png"; 77 | }, 78 | complete() { 79 | // this notification will be received after next () 80 | // as a result of the once member being set to true 81 | } 82 | }) 83 | ``` 84 | 85 | Note that the `receiveError` member of the `OnOptions` object is set to true. Therefore if the Image receives an `"error"` Event, the Event is passed to the `error` method of each of the `Observable`'s `Observer`s. This, too, results in unsubscription from all of the Image's underlying events. 86 | 87 | 88 | ### The `OnOptions` `handler` member 89 | 90 | The `handler` callback function is invoked on the event object prior to the event being dispatched to the Observable's Observers. The handler gives developers the ability execute stateful operations on the Event object (ex. `preventDefault`, `stopPropagation`), within the same tick on the event loop as the event is received. 91 | 92 | In the example below, event composition is used build a drag method for a button to allow it to be absolutely positioned in an online WYSWYG editor. Note that the `handler` member of the `OnOptions` object is set to a function which prevents the host browser from initiating its default action. This ensures that the button does not appear pressed when it is being dragged around the design surface. 93 | 94 | ```js 95 | import "_" from "lodash-for-observable"; 96 | 97 | const button = document.querySelector("#button"); 98 | const surface = document.querySelector("#surface"); 99 | 100 | // invoke preventDefault() in handler to suppresses the browser default action 101 | // which is to depress the button. 102 | const opts = { handler(e) { e.preventDefault(); } }; 103 | const mouseDowns = _(button.on( "mousedown", opts)); 104 | const mouseMoves = _(surface.on("mousemove", opts)); 105 | const mouseUps = _(surface.on("mouseup", opts)); 106 | 107 | const mouseDrags = mouseDowns.flatMap(() => mouseMoves.takeUntil(mouseUps)); 108 | 109 | mouseDrags.subscribe({ 110 | next(e) { 111 | button.style.top = e.offsetX; 112 | button.style.left = e.offsetY; 113 | } 114 | }) 115 | ``` 116 | 117 | ## Example Implementation 118 | 119 | This is an example implementation of ObservableEventTarget. The `on` method delegates to 120 | `addEventListener`, and adds a handler for an `"error"` event if the `receiveError` member on the `OnOptions` object has a value of `true`. 121 | 122 | ```js 123 | class ObservableEventTarget extends EventTarget { 124 | on(type, opts) { 125 | return Observable(observer => { 126 | if (typeof opts !== "boolean") { 127 | opts = {}; 128 | } 129 | else { 130 | opts = { 131 | capture: opts 132 | }; 133 | } 134 | 135 | const handler = (typeof opts.handler === "function") ? opts.handler : null; 136 | const once = opts.once; 137 | 138 | const eventHandler = e => { 139 | try { 140 | if (handler != null) { 141 | handler(e); 142 | } 143 | 144 | observer.next(e); 145 | } 146 | finally { 147 | if (once) { 148 | observer.complete(); 149 | } 150 | } 151 | }; 152 | 153 | const errorHandler = observer.error.bind(observer); 154 | 155 | this.addEventListener(type, eventHandler, opts); 156 | 157 | if (opts.receiveError) { 158 | this.addEventListener("error", errorHandler) 159 | } 160 | 161 | // unsubscription logic executed when either the complete or 162 | // error method is invoked on Observer, or the consumer 163 | // unsubscribes. 164 | return () => { 165 | this.removeEventListener(type); 166 | 167 | if (receiveError) { 168 | this.removeEventListener("error", errorHandler); 169 | } 170 | }; 171 | }); 172 | } 173 | } 174 | ``` 175 | 176 | ## Problem: EventTargets and Promises are difficult to Compose 177 | 178 | Web applications need to remain responsive to user input while performing long-running operations like network requests. Consequently web applications often subscribe to EventTargets and Promises concurrently. In some circumstances, an application may start additional concurrent operations when each new event of a particular type is received (ex. a web browser starting a new concurrent download for each file link clicked). However web applications often respond to events by **canceling or ignoring the output of one or more concurrently running tasks** (ex. canceling an outstanding request for a view's data when the user navigates elsewhere). 179 | 180 | Unfortunately this common concurrency coordination pattern, in which outstanding network requests are canceled when an event of a certain type is received, is challenging to implement compositionally using EventTargets and Promises. These challenges will be demonstrated using the use case of an image browser app created for a news aggregator. 181 | 182 | ### Use Case: Browsing the Images in a News aggregator 183 | 184 | Consider the use case of a web app which allows users to browse through images posted on a news aggregator site. 185 | 186 | ![Aggregator](http://tc39.github.io/proposal-observable/aggregator.png) 187 | 188 | A user can select from several image-oriented subs using a select box. Each time a new sub is selected, the app downloads the first 300 post summaries from that sub. Once the posts have been loaded, the user can navigate through the images associated with each post using a next and previous button. When the user navigates to a new post, the image is displayed as soon as it has been successfully preloaded. If the image load is not successful, or the post does have an associated image, a placeholder image is displayed. Whenever data is being loaded from the network, a transparent animated loading image is rendered over top of the image. 189 | 190 | This app may appear simple, but implementations could suffer from any of the following race conditions: 191 | 192 | * In the event requests for a sub's posts complete out of order, images from old subs may be displayed after images from subs selected by the user more recently. 193 | * In the event image preloads complete out of order, old images may be displayed after images selected by the user more recently. 194 | * While a new sub is being loaded, the UI may continue responding to the navigation events for the current sub. Consequently images from the old sub may be displayed briefly before abruptly being replaced by those in the newly-loaded sub. 195 | 196 | Note that all of these race conditions have one thing in common: they can be avoided by unsubscribing from a pending network request or an event type when an event is received. In the following subsections, two solutions will be contrasted: 197 | 198 | 1. Coordinating concurrency using shared mutable state 199 | 2. Coordinating concurrency compositionally using EventTargetObservable and a library 200 | 201 | #### Solution: Coordinate Concurrency using Shared Mutable State 202 | 203 | Consider the following solution, which coordinates concurrent subscriptions to both EventTargets and Promises using shared mutable state. 204 | 205 | ```js 206 | const subSelect = document.querySelector('#subSelect'); 207 | const displayedImage = document.querySelector("#displayedImage"); 208 | const titleLabel = document.querySelector("#titleLabel"); 209 | const previousButton = document.querySelector("#previousButton"); 210 | const nextButton = document.querySelector("#nextButton"); 211 | 212 | // shared mutable state used to track the currently displayed image 213 | let index; 214 | 215 | // shared mutable state used to coordinate concurrency 216 | let posts; 217 | let currentOperationToken = {}; 218 | 219 | function showProgress() { 220 | progressImage.style.visibility = "visible"; 221 | } 222 | 223 | function switchImage(direction) { 224 | // guard against navigating while a new sub is being loaded 225 | if (posts == null) { 226 | return; 227 | } 228 | showProgress(); 229 | 230 | if (posts) { 231 | index = circularIndex(index + direction, posts.length) 232 | } 233 | 234 | const summary = posts[index]; 235 | 236 | // capture current operation id in closure so it can be used to 237 | // confirm operation is not outdated when Promise resolves 238 | currentOperationToken = {}; 239 | let thisOperationToken = currentOperationToken; 240 | 241 | return preloadImage(summary.image). 242 | then( 243 | () => { 244 | // noop if this is no longer the current operation 245 | if (thisOperationToken === currentOperationToken) { 246 | titleLabel.innerText = summary.title 247 | displayedImage.src = detail.image || "./noimagefound.gif" 248 | } 249 | }, 250 | e => { 251 | // noop if this is no longer the current operation 252 | if (thisOperationToken === currentOperationToken) { 253 | titleLabel.innerText = summary.title 254 | displayedImage.src = "./errorloadingpost.png"; 255 | } 256 | }); 257 | } 258 | 259 | function subSelectHandler() { 260 | showProgress(); 261 | 262 | let sub = subSelect.value; 263 | // indicate a new set of posts is being loaded to guard 264 | // against responding to navigation events in the interim 265 | posts = null; 266 | 267 | // capture current operation id in closure so it can be used to 268 | // confirm operation is not outdated when Promise resolves 269 | 270 | currentOperationToken = {}; 271 | let thisOperationToken = currentOperationToken; 272 | newsAggregator. 273 | getSubPosts(sub). 274 | then( 275 | postsResponse => { 276 | if (thisOperationToken === currentOperationToken) { 277 | index = 0; 278 | posts = postsResponse; 279 | return switchImage(0); 280 | } 281 | }, 282 | e => { 283 | // unsubscribe from events to avoid putting unnecessary 284 | // load on news aggregator when the server is down. 285 | nextButton.removeEventListener("click", nextHandler); 286 | previousButton.removeEventListener("click", previousHandler); 287 | subSelect.removeEventListener("change", subSelectHandler); 288 | alert("News Aggregator is not responding. Please try again later."); 289 | }); 290 | }); 291 | 292 | function nextHandler() { 293 | switchImage(1) 294 | }; 295 | 296 | function previousHandler() { 297 | switchImage(-1); 298 | }; 299 | 300 | subSelect.addEventListener("change", subSelectHandler); 301 | nextButton.addEventListener("click", nextHandler); 302 | previousButton.addEventListener("click", previousHandler); 303 | 304 | // load current sub 305 | subSelectHandler(); 306 | ``` 307 | 308 | In the example solution above race conditions are avoided by using shared mutable state to track the current operation, and explicit guards are used to avoid responding to outdated operations. 309 | 310 | ```js 311 | if (posts == null) { 312 | return; 313 | } 314 | 315 | // ...snip... 316 | 317 | if (thisOperationToken === currentOperationToken) { 318 | // ...snip... 319 | } 320 | ``` 321 | 322 | Failure to explicitly include these guards can lead to race conditions which calls notifications to be processed out of order. Furthermore the inability to unsubscribe from Promises means that these guards must be explicitly inserted in both the resolve and reject callbacks. 323 | 324 | Yet more shared mutable state is necessary because EventTarget and Promise do not compose. Note that in order to make the values resolved by `Promises` available to `EventTarget` handlers, it is necessary to write them to the shared mutable `posts` variable. 325 | 326 | #### Alternate Solution: ObservableEventTarget and a small combinator library 327 | 328 | Canceling or ignoring the output of a concurrent operation when a new event is received is one of the most common concurrency coordination patterns used in web applications. This common coordination pattern can be encapsulated in a single Observable method: `switchLatest`. 329 | 330 | The `switchLatest` combinator transforms a multi-dimensional Observable into an Observable flattened by one dimension. As soon the outer Observable notifies an inner Observable, `switchLatest` unsubscribes from the currently-subscribed inner Observable and subscribes to the most recently notified inner Observable. 331 | 332 | The behavior of the `switchLatest` function can be understood more easily through the use of a textual representation. Consider the following textual representation of an Observable: 333 | 334 | ```<|,,,1,,,,,5,,,,,,,9,,,,|>``` 335 | 336 | In the representation above each ```<|``` is the point at which the Observable is subscribed, and each ```|>``` indicates a `complete` notification. Furthermore each `,` represents 10 milliseconds and each number represents a `next` notification to the Observer. 337 | 338 | A program in which a network request is sent for each event in an event stream can be modeled as a two-dimensional Observable... 339 | 340 | ``` 341 | <| 342 | ,,,,,<|,,,,,,,,,,,,,,,,,,,,,,88,,,,|> 343 | ,,,,,,,,,<|,,,,,33,,,,,,,,,,,,,|> 344 | ,,,,,,,,,,,,,,,,,,,,,<|,,,,,,,,,9|> 345 | ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,|> 346 | ``` 347 | 348 | If the `switchLatest` function is applied to flatten the two-dimensional Observable above, the following result is produced. 349 | 350 | `<|,,,,,,,,,,,,,,,,33,,,,,,,,,,,9,,,,,,,|>` 351 | 352 | Note that none of the data in the first inner `Observable` makes it into the flattened stream, because the first inner `Observable` does not notify prior to the notification of a new inner `Observable`. Consequently the `switchLatest` combinator unsubscribes from the previous inner `Observable` before that `Observable` has the opportunity to notify. The second inner `Observable` only has the opportunity to notify `8` prior to the arrival of a new inner `Observable`, which notifies `9` and completes. Shortly thereafter the outer `Observable` completes, thereby completing the flattened Observable. 353 | 354 | Here's an example of `switchLatest` being used to build an auto-complete box: 355 | 356 | ```js 357 | import _ from "lodash-for-observable"; 358 | const textbox = document.querySelector("#textbox"); 359 | let keyups = _.on(textbox, "keyup"); 360 | 361 | keyups. 362 | // disregard outstanding request and switch to 363 | // new one created by projection function. 364 | map(() => 365 | // userland "lodash-for-observable" library 366 | // automatically adapts Promises into Observables 367 | getSearchResultSet(textbox.value)). 368 | switchLatest(). 369 | subscribe({ 370 | next(resultSet) { 371 | display(resultSet); 372 | }, 373 | error(error) { 374 | alert(error); 375 | } 376 | }); 377 | ``` 378 | 379 | Note that using `switchLatest` guarantees that search results for a particular search not come back out-of-order by switching to the the result of the latest Promise each time a key is pressed. 380 | 381 | In the example above the `switchLatest` operation is applied to the result of a `map` operation. The `switchMap` method is a shorthand for this common pattern. Here is the example above rewritten to use `switchMap`. 382 | 383 | ```js 384 | import _ from "lodash-for-observable"; 385 | const textbox = document.querySelector("#textbox"); 386 | let keyups = _.on(textbox, "keyup"); 387 | 388 | keyups. 389 | // disregard outstanding request and switch to 390 | // new one created by projection function. 391 | switchMap(() => 392 | // userland "lodash-for-observable" library 393 | // automatically adapts Promises into Observables 394 | getSearchResultSet(textbox.value)). 395 | subscribe({ 396 | next(resultSet) { 397 | display(resultSet); 398 | }, 399 | error(error) { 400 | alert(error); 401 | } 402 | }); 403 | ``` 404 | 405 | Here's an algorithm for the Image Viewer app which uses `switchMap` to avoid race conditions without relying on shared mutable state. 406 | 407 | ```js 408 | import newsAggregator from "news-aggregator"; 409 | import _ from "lodash-for-observable"; 410 | 411 | const previousClicks = _(previousButton.on("click")); 412 | const nextClicks = _(nextButton.on("click")); 413 | 414 | const getNavigatedItems = (array) => 415 | _.merge( 416 | Observable.of(0), 417 | backClicks.map(() => -1), 418 | forwardClicks.map(() => 1)). 419 | // <|0,,,,,,,,,,1,,,,,,,,,1,,,,,,,-1,,,,,,,,-1,,,,,,,, 420 | scan( 421 | (index, direction) => 422 | circularIndex(index + direction, length)). 423 | // <|0,,,,,,,,,,1,,,,,,,,,2,,,,,,,,1,,,,,,,,0,,,,,,,,, 424 | map(index => array[index]); 425 | // <|item,,,,,,,item,,,,,,item,,,,,,item,,,,item,,,,,, 426 | 427 | const subSelect = document.querySelector('#subSelect'); 428 | // ,,,,,,"pics",,,,,,"humour",,,,,,,,,,,,"cute",,,,,,, 429 | const subs = _(subSelect.on("change")).map(e => e.target.value); 430 | 431 | _. 432 | merge(backClicks, forwardClicks, subs). 433 | subscribe(() => progressImage.style.visibility = "visible"); 434 | 435 | const postsWithImages = 436 | subs. 437 | // ,,,,,"pics",,,,"humour",,,,,,,,,,"cute",,,,,,,, 438 | switchMap(sub => // ignore outstanding sub requests, nav events, and image loads and switch to new sub 439 | newsAggregator.getSubPosts(sub, 300). 440 | //,,,[ {title:"My Cat", image:"http://"}, {title:"Meme",image:"http://"}, ...],,,,,[...],,,, 441 | switchMap(posts => getNavigatedItems(posts)). 442 | //,,,{title:"My Cat",image:"http://"},,,,,,,,{title:"Meme",image:"http://"},,,,,,,,,,,,, 443 | switchMap(post => { // ignore outstanding image loads, switch to new post 444 | const image = new Image(); 445 | image.src = post.image; 446 | return _.(image.on('load', { receiveError: true, once: true })). 447 | map(() => post). 448 | catch(error => Observable.of({...post, image: "./errorloadingpost.png"})); 449 | })); 450 | //,,,,,,,,,,,,{title: "My Cat",image: "http://...""},,,,,,,{title:"Meme",image:"http://"},,,,,,,,, 451 | 452 | const displayedImage = document.querySelector("#displayedImage"); 453 | const titleLabel = document.querySelector("#titleLabel"); 454 | const progressImage = document.querySelector("#progressImage"); 455 | 456 | postDetails.subscribe({ 457 | next({title, image}) { 458 | progressImage.style.visibility = "hidden"; 459 | titleLabel.innerHTML = title; 460 | displayedImage.src = image; 461 | } 462 | error(e) { 463 | alert("News Aggregator is not responding. Please try again later."); 464 | } 465 | }); 466 | ``` 467 | 468 | Note that the resulting code is shorter than the correct previous solution. More importantly the code contains does not utilize any shared mutable state for concurrency coordination. 469 | 470 | ## More Compositional Web Applications with ObservableEventTarget 471 | 472 | The web platform has much to gain by including a primitive which can allow EventTargets and Promises to be composed. This proposal, along with the Observable proposal currently being considered by the TC-39, are incremental steps towards a more compositional approach to concurrency coordination. If the Observable proposal is accepted, the Observable prototype will have the opportunity to be enriched with useful combinators over time, eliminating the need for a combinator library in common cases. 473 | -------------------------------------------------------------------------------- /Can Observable be built on Cancel Tokens.md: -------------------------------------------------------------------------------- 1 | # Can Observable be built on CancelTokens rather than Subscriptions? 2 | 3 | The current proposal specifies that the Observable prototype contains two methods that allow a consumer to receive their notifications: 4 | 5 | 1. forEach 6 | 2. subscribe 7 | 8 | The forEach function accepts a Cancel Token, and executes unsubscription logic when a cancellation notification is received from the token. 9 | 10 | In contrast, the subscribe method returns a Subscription object. Subscription objects are basically thunks which synchronously execute unsubscription logic. 11 | 12 | It has been proposed that Observable’s `subscribe` method should accept a `CancelToken` rather than returning a `Subscription` object. The main rationale for this change is reduced API surface area. Rather than learning about two concepts which can be used to cancel an asynchronous operation, developers would only have to learn about one. 13 | 14 | 15 | This document will explore whether Observable can be built on cancel tokens, and outline what changes (if any) would need to be made to both the cancelable promises and observable proposals. 16 | 17 | ## Replacing Subscriptions with CancelTokens 18 | 19 | In order to implement Observable with CancelTokens rather than Subscriptions, the following changes must be made to the observable and cancelable promises specifications respectively: 20 | 21 | 1. Modify subscribe to accept a CancelToken instead of returning Subscription 22 | 2. Replace `error` method in Observer with `else` and `catch` 23 | 3. Replace SubscriptionObserver with CancelTokenObserver 24 | 4. Ensure CancelTokens weakly reference input tokens 25 | 5. Ensure CancelTokens propagate cancellation notifications synchronously 26 | 27 | ### Modifying subscribe to accept a CancelToken instead of returning a Subscription 28 | 29 | This is the API of the Subscription-based Observable: 30 | ```js 31 | interface Observable { 32 | constructor(subscriber : SubscriberFunction); 33 | subscribe(observer : Observer) : Subscription; 34 | // more snipped... 35 | } 36 | 37 | function SubscriberFunction(observer: SubscriptionObserver) : (void => void) | Subscription; 38 | ``` 39 | 40 | In order to build Observable on cancel tokens, the API of the Observable would need to be changed to this: 41 | 42 | ```js 43 | interface Observable { 44 | constructor(subscriber : SubscriberFunction); 45 | subscribe(observer : Observer, token : CancelToken | void) : void; 46 | // more snipped. 47 | } 48 | function SubscriberFunction(observer: CancelTokenObserver, token : CancelToken) : void 49 | ``` 50 | 51 | 52 | Here’s an example of a CancelToken-based Observable being created and consumed: 53 | 54 | 55 | ```js 56 | let bodyMouseMoves = new Observable((observer, token) => { 57 | handler = event => observer.next(event); 58 | token.promise.then(() => document.body.removeEventListener("mousemove", handler)); 59 | document.body.addEventListener("mousemove", handler); 60 | }); 61 | 62 | let { token, cancel } = CancelToken.source(); 63 | bodyMouseMoves.subscribe( 64 | { 65 | next(event) { 66 | if (event.clientX <= 50) { 67 | console.log("mouse hasn’t moved passed 50px"); 68 | } 69 | else { 70 | token.cancel(new Cancel("Only want to listen to mouse until it moves passed 50px")); 71 | } 72 | } 73 | }, 74 | token); 75 | ``` 76 | 77 | 78 | Note that in order to cancel a subscription, the consumer must cancel the token passed to `subscribe`. The Cancelable Promises proposal dictates that when an asynchronous function is canceled, the Promise must resolve to a Cancel object. In order to allow developers to avoid inadvertently catching Cancel objects, the Cancelable Promises proposal adds a `else` method to the Promise prototype. This method only receives rejected values which are not `Cancel` instances, allowing Cancel instances to propagate. 79 | 80 | 81 | If Observables are to be built on cancel tokens, consumers must be able to differentiate whether a subscription closed due to either cancellation or error. 82 | 83 | ### Replacing `error` method in Observer with `else` and `catch` 84 | 85 | 86 | In the Subscription-based proposal, Observers have only one method which receives errors: 87 | 88 | 89 | ```js 90 | interface Observer { 91 | next(v:any):any 92 | error(e:anythingButCancel):any 93 | complete(v:any):any 94 | } 95 | ``` 96 | 97 | 98 | If Subscriptions are to be replaced with CancelTokens, consumers of Observables must be differentiate whether a subscription was closed due to cancellation or error. One way of accomplishing this is to replace Observer’s `error` method with two methods: `else` and `catch`. 99 | 100 | 101 | ```js 102 | interface Observer { 103 | next(v:any):any 104 | else(e:anythingButCancel):any 105 | catch(e:any):any 106 | complete(v:any):any 107 | } 108 | ``` 109 | 110 | 111 | Note that the new Observer methods correspond to the Promise prototype methods proposed in the Cancelable Promises Proposal. If the `else` method is defined, the Observable will call it with the value - provided that the value is not a `Cancel` instance. Otherwise if the `catch` method is defined on the Observer, `catch` will be passed the value regardless of whether it is a Cancel instance or not. 112 | 113 | 114 | This raises an important question: how will usercode which catches errors invoke the right method on the Observer? The Cancelable Promises proposal does not currently provide a brand check for `Cancel` instances. Furthermore `try` statements cannot contain both an `else` and `catch` block. 115 | 116 | 117 | In the next section a change to the subscribe API will be proposed to enable Observable implementations to notify the right method on the Observer if a subscription is closed due to failure. 118 | 119 | ### Replacing SubscriptionObserver with CancelTokenObserver 120 | 121 | The Subscription-based proposal specifies a SubscriptionObserver. 122 | 123 | ```js 124 | interface SubscriptionObserver { 125 | 126 | // Sends the next value in the sequence 127 | next(value); 128 | 129 | // Sends the sequence error 130 | error(errorValue); 131 | 132 | // Sends the sequence completion value 133 | complete(completeValue); 134 | 135 | // A boolean value indicating whether the subscription is closed 136 | get closed() : Boolean; 137 | } 138 | ``` 139 | 140 | In the Subscription-based proposal, a SubscriptionObserver is created which wraps the input Observer whenever `subscribe` is invoked. Then the `subscribe` method passes the SubscriptionObserver to the subscribe implementation provided to the Observable constructor. 141 | Wrapping the observer in a SubscriptionObserver is beneficial for the following reasons: 142 | 143 | * it normalizes the input Observer API, ensuring that all methods are present. 144 | * it ensures that no notifications are delivered to the Observer after the subscription is closed. 145 | 146 | 147 | if Subscriptions are replaced with Cancel Tokens, it is necessary to replace the `SubscriptionObserver` with a `CancelTokenObserver`. 148 | 149 | 150 | ```js 151 | class CancelTokenObserver { 152 | // Sends the next value in the sequence 153 | next(value); 154 | 155 | // If wrapped observer is CancelTokenObserver 156 | // calls throw on wrapped observer 157 | // Else if errorValue is _not_ Cancel and wrapped observer has else method 158 | // calls else on wrapped observer 159 | // Else if wrapped observer has catch method 160 | // calls catch on wrapped observer 161 | throw(errorValue); 162 | 163 | // Receives all error values except Cancels 164 | else(errorValue); 165 | 166 | // When present alongside else method, receives only Cancels. If else method does not exist on Observer, receives all errors. 167 | catch(errorValue); 168 | 169 | // Sends the sequence completion value 170 | complete(completeValue); 171 | } 172 | ``` 173 | 174 | 175 | The `CancelTokenObserver` provides the same benefits as the `SubscriptonObserver`. However in addition to the Observer contract, the CancelTokenObserver prototype contains a `throw` method. 176 | 177 | 178 | If the Observer wrapped by CancelTokenObserver is a CancelTokenObserver, the throw method will forward the value to the wrapped Observer’s throw method. Otherwise if an `else` method is defined on the Observer wrapped by CancelTokenObserver, the throw method forwards its input value to that `else` method - provided that the input value is not a `Cancel` instance. Otherwise if the `catch` method is defined on the Observer wrapped by CancelTokenObserver then the throw method will forward its input to `catch`. Finally if no suitable method on the observer can be found to receive the caught value, the error will be logged using HostReportErrors. 179 | 180 | 181 | In order to leverage throw, implementations of `subscribe` should always use the `catch` clause when invoking an operation that may fail. If a value is caught, the subscribe implementation should pass the value to the `CancelTokenObserver`’s throw method. This will ensure that the value is delegated to the correct method on the `Observer`. 182 | 183 | 184 | Here’s an example of this pattern in action: 185 | 186 | 187 | ```js 188 | function map(observable, projection) { 189 | return new Observable((observer, token) => { 190 | const self = this; 191 | let index = 0; 192 | observable.subscribe( 193 | { 194 | next(value) { 195 | try { 196 | value = projection(value, index++, self); 197 | } 198 | catch(e) { 199 | return observer.throw(e); 200 | } 201 | 202 | 203 | observer.next(value); 204 | }, 205 | catch(e) { 206 | observer.throw(e); 207 | }, 208 | complete(v) { 209 | observer.complete(v); 210 | } 211 | }, 212 | token); 213 | }); 214 | } 215 | ``` 216 | 217 | 218 | If it's input token is canceled, Observable’s subscribe method will notify the observer’s`catch` method. Here’s a polyfill of the Observable constructor and the `subscribe` method demonstrating how the Observer is notified on token cancellation. 219 | 220 | 221 | ```js 222 | class Observable { 223 | constructor(subscriber) { 224 | // The stream subscriber must be a function 225 | if (typeof subscriber !== "function") 226 | throw new TypeError("Observable initializer must be a function"); 227 | 228 | 229 | this._subscriber = subscriber; 230 | } 231 | 232 | 233 | subscribe(observer, token) { 234 | if (Object(observer) !== observer) { 235 | throw new TypeError(observer + " is not a object"); 236 | } 237 | 238 | 239 | if (token != null && Object(token) !== token) { 240 | throw new TypeError(token + " is not an object"); 241 | } 242 | 243 | 244 | if (token == null) { 245 | token = new CancelToken(() => {}); 246 | } 247 | 248 | 249 | token.promise.then(cancel => observer.catch(cancel)); 250 | observer = new CancelTokenObserver(observer, token); 251 | 252 | 253 | const reason = token.reason; 254 | if (reason) { 255 | return observer.catch(reason); 256 | } 257 | 258 | 259 | try { 260 | this._subscriber(observer, token); 261 | } catch(e) { 262 | observer.throw(e); 263 | } 264 | } 265 | // rest snipped... 266 | } 267 | ``` 268 | 269 | 270 | On the surface the CancelToken-based implementation of `subscribe` appears to be more efficient than the Subscription-based implementation. Note that a single `CancelToken` may be used by many Observables. In the Subscription-based proposals, many functions which compose Observables generate a Subscription per call to `subscribe`. Under the circumstances it would appear as though the CancelToken-based proposal may require fewer allocations than the Subscription-based proposal. 271 | 272 | 273 | Unfortunately the implementation above will leak memory when some common Observable compositions are applied. In the following sections this memory leak will be explained, a change to the implementation will be proposed to avoid it, and finally a change to the CancelToken specification will be rationalized. 274 | 275 | ### Modifying `CancelToken.prototype.race` to weakly retain references to all input tokens 276 | 277 | 278 | The implementation of `subscribe` in the previous section does not allow certain common composition operations to be written without introducing a memory leak. As an example, consider the `flatten` function which is commonly applied to Observables: 279 | 280 | ```js 281 | import _ from "lodashforObservables"; 282 | 283 | let mouseDowns = document.body.on(‘mousedown’); 284 | let mouseUps = document.body.on(‘mouseup’); 285 | let mouseMoves = document.body.on(‘mousemove’); 286 | 287 | let mouseDrags = 288 | _(mouseDowns). 289 | map(() => _(mouseMoves).takeUntil(mouseUps)). 290 | flatten(); 291 | ``` 292 | 293 | The code above creates an Observable that notifies all of the mouse moves that occur between a mouse down and mouse up event. 294 | 295 | The `flatten` function accepts an input Observable of Observables, and returns a new Observable which notifies its Observer of the data in each of the Observables in the input Observable. Consider the following example: 296 | 297 | 298 | ```js 299 | import _ from "lodashforObservables"; 300 | 301 | flatten(Observable.of( 302 | Observable.of(1,2,3), 303 | Observable.of(4,5,6))). 304 | forEach(x => console.log(x)); 305 | ``` 306 | 307 | 308 | With the code above is run, the following console output is expected: 309 | 310 | ``` 311 | 1 312 | 2 313 | 3 314 | 4 315 | 5 316 | 6 317 | ``` 318 | 319 | The `flatten` function allows an long-running asynchronous operation to be composed together from multiple smaller asynchronous operations. Consider the following code which looks for a stock that matches a particular criteria: 320 | 321 | ```js 322 | import _ from "lodashforObservables"; 323 | async function buyFirstMatchStock(stocks) { 324 | var stockInfo = await _(stocks). 325 | map(symbol => 326 | _.fromPromise(getStockInfoPromise(symbol)). 327 | flatten(). 328 | filter(stockInfo => matchesLocalCritera(stockInfo)). 329 | first(); 330 | 331 | let purchasePrice = await purchase(stockInfo); 332 | return purchasePrice; 333 | } 334 | ``` 335 | 336 | Note that this async function may run for a long time, as well as spawn many smaller async operations as it retrieves the info for each stock from a remote service. 337 | 338 | Now consider the following implementation of `flatten`: 339 | 340 | ```js 341 | function flatten(observable) { 342 | return new Observable((observer, token) => { 343 | let outerObservableDone = false; 344 | let innerObservables = 0; 345 | 346 | observable.subscribe({ 347 | next(innerObservable) { 348 | innerObservables++; 349 | 350 | innerObservable.subscribe( 351 | { 352 | next(value) { 353 | observer.next(value); 354 | }, 355 | catch(e) { 356 | innerObservables--; 357 | observer.throw(e); 358 | }, 359 | complete(v) { 360 | innerObservables--; 361 | if (innerObservables === 0 && outerObservableDone) { 362 | observer.complete(v); 363 | } 364 | } 365 | }, 366 | token); 367 | }, 368 | catch(e) { 369 | observer.throw(e); 370 | }, 371 | complete(v) { 372 | outerObservableDone = true; 373 | if (innerObservables === 0) { 374 | observer.complete(v); 375 | } 376 | } 377 | }, 378 | token); 379 | }); 380 | } 381 | ``` 382 | 383 | This implementation of flatten contains a memory leak. In the following sections, the root cause of the leak will be explained, and a solution will be proposed. 384 | 385 | #### Memory leaks and the inability to unsubscribe from cancellation notifications 386 | 387 | In the previous section it was suggested that the implementation of flatten had a memory leak. However rather than focus on the definition of flatten, it will be demonstrated that the root cause of the leak is in the implementation of Observable.prototype.subscribe suggested earlier in this document. 388 | 389 | 390 | Consider the (truncated) definition of Observable.prototype.subscribe again: 391 | 392 | 393 | ```js 394 | class Observable { 395 | subscribe(observer, token) { 396 | // input validation snipped... 397 | if (token == null) { 398 | token = new CancelToken(() => {}); 399 | } 400 | observer = new CancelTokenObserver(observer, token); 401 | token.promise.then(cancel => observer.catch(cancel)); 402 | // call to subscribe implementation snipped... 403 | } 404 | // snip... 405 | } 406 | ``` 407 | 408 | Note that subscribe attaches a handler to the input token’s Promise in order to inform the Observer in case of token cancellation. Furthermore note that when the input token is passed to the subscribe implementation, the implementation may also attach cleanup logic to the token to be executed if the token is canceled. This enables subscribe implementations to free resources in the event the subscription is closed due to cancellation. 409 | 410 | There is a problem with using this approach to ensure that resources are cleaned up when a subscription is closed. JavaScript’s proposed cancel tokens use a Promise to notify consumers of cancellation. 411 | 412 | ```js 413 | var source = CancelToken.source(); 414 | var token = source.token; 415 | token.promise.then(cancel => { 416 | // cleanup logic 417 | }); 418 | ``` 419 | 420 | Note that there is no way to detach a handler to a Promise in JavaScript except to resolve the Promise. That means that **once a cancellation handler has been attached to a cancel token, it cannot be detached until the token is canceled.** JS CancelTokens are notably different than .NET Cancellation Tokens in this respect, because .NET Cancellation Tokens allow handlers to be unsubscribed using an Observer-like interface. 421 | 422 | ```cs 423 | CancellationTokenSource source = new CancellationTokenSource(); 424 | CancellationToken token = source.Token; 425 | 426 | // register 427 | CancellationTokenRegistration registration = token.Register(() => { 428 | // cleanup logic 429 | }) 430 | 431 | // unregister 432 | registration.Dispose(); 433 | ``` 434 | 435 | The inability to detach a cancellation handler from a cancel token is the root cause of the memory leak in flatten. When a subscription closes, the cleanup logic the implementation registered with the token cannot detached. As a result the current implementation leaks memory when operations like flatten are applied, which can create a long running async Observables out of many (potentially short-lived) Observables. As the flattened Observable subscribes to inner Observables, cleanup logic may be attached to the token. However as each of these subscription closes, cleanup logic is not run, nor is the handler detached. 436 | 437 | The implementation of `flatten` below is identical to the one included earlier in the document, but annotates the code with comments detailing the memory leak: 438 | 439 | ```js 440 | function flatten(observable) { 441 | return new Observable((observer, token) => { 442 | let outerObservableDone = false; 443 | let innerObservables = 0; 444 | 445 | // Each Observable received from this stream will attach a 446 | // handler to the token on subscription. This handler will remain 447 | // attached until the token received by `subscribe` is cancelled - 448 | // even after the subscription to the Observable has closed. 449 | observable.subscribe({ 450 | next(innerObservable) { 451 | innerObservables++; 452 | 453 | // handler attached to token.promise 454 | innerObservable.subscribe( 455 | { 456 | next(value) { 457 | observer.next(value); 458 | }, 459 | catch(e) { 460 | // handler not detached from promise, 461 | // even though innerObservable subscription 462 | // is closed 463 | innerObservables--; 464 | observer.throw(e); 465 | }, 466 | complete(v) { 467 | // handler not detached from promise, 468 | // even though innerObservable subscription 469 | // is closed 470 | innerObservables--; 471 | if (innerObservables === 0 && outerObservableDone) { 472 | observer.complete(v); 473 | } 474 | } 475 | }, 476 | token); 477 | }, 478 | catch(e) { 479 | observer.throw(e); 480 | }, 481 | complete(v) { 482 | outerObservableDone = true; 483 | if (innerObservables === 0) { 484 | observer.complete(v); 485 | } 486 | } 487 | }, 488 | token); 489 | }); 490 | } 491 | ``` 492 | 493 | The `flatten` operator demonstrates that a long-running operation cannot share CancelToken’s among many Observables without leaking memory. In the next section we’ll modify the polyfill `subscribe` to allow each implementation to free its resources once the subscription closes. 494 | 495 | #### Enabling Observables to cleanup when a subscription is closed 496 | 497 | In the previous section we demonstrated that sharing a single cancel token among multiple Observables can create memory leaks when long-running asynchronous operations are composed out of many smaller asynchronous operations. One solution to this problem is to create a new CancelToken for each call to subscribe. 498 | 499 | Consider the following (revised) polyfill of Observable.prototype.subscribe: 500 | 501 | ```js 502 | export class Observable { 503 | subscribe(observer, outerToken) { 504 | let token; 505 | // argument validation omitted... 506 | 507 | // Create new CancelToken for this Subscription operation and 508 | // link it to the Observable. 509 | const { token: innerToken, cancel } = CancelToken.source(); 510 | token = outerToken != null ? CancelToken.race([outerToken, innerToken]) : innerToken; 511 | 512 | // The cancel fn is passed to the CancelTokenObserver so that it 513 | // can cleanup subscription resources when it receives a 514 | // else or complete notification 515 | observer = new CancelTokenObserver(observer, token, cancel); 516 | token.promise.then(c => observer.catch(c)); 517 | 518 | const reason = token.reason; 519 | if (reason) { 520 | return observer.catch(reason); 521 | } 522 | 523 | try { 524 | this._subscriber(observer, token); 525 | } catch(e) { 526 | observer.throw(e); 527 | } 528 | } 529 | ``` 530 | 531 | 532 | Note that Observable’s subscribe method creates a new CancelTokenSource each time it is invoked. Then a raced token is created created from the source token and the input token, and the raced token is subsequently passed to the subscribe implementation. The net effect is that a cancel token is created specifically for each subscription. 533 | 534 | In order to ensure that a subscribe implementation’s clean up logic is executed when the subscription is closed, the source’s cancel function is passed to the `CancelTokenObserver` along with the token. The CancelTokenObserver runs the cancel function whenever an `else` or `complete` notification is received. This causes cleanup logic to be run whenever a subscription is closed. 535 | 536 | Note this logic in the polyfill of `CancelTokenObserver.prototype.else` below: 537 | 538 | ```js 539 | // Abstract operation 540 | function closeCancelTokenObserver(cancelTokenObserver) { 541 | cancelTokenObserver._closed = true; 542 | cancelTokenObserver._observer = undefined; 543 | cancelTokenObserver._subscriptionCancel = new Cancel("Subscription canceled."); 544 | cancelTokenObserver._cancel(cancelTokenObserver._subscriptionCancel); 545 | } 546 | 547 | class CancelTokenObserver { 548 | constructor(observer, token, cancel) { 549 | this._observer = observer; 550 | this._token = token; 551 | this._cancel = cancel; 552 | } 553 | // other methods snipped... 554 | complete(value) { 555 | // if token is cancelled, noop 556 | if (isCancelTokenObserverTokenCancelled(this)) { 557 | return; 558 | } 559 | 560 | 561 | let observer = this._observer; 562 | // close subscription by cancelling token 563 | closeCancelTokenObserver(this); 564 | 565 | 566 | let m = getMethod(observer, "complete"); 567 | 568 | 569 | if (m) { 570 | try { 571 | m.call(observer, value); 572 | } 573 | catch(e) { 574 | // HostReportErrors(e) 575 | } 576 | } 577 | } 578 | } 579 | ``` 580 | 581 | Note the `complete` method cancels the token created specifically for this subscription, ensuring that resources are cleaned up when the subscription closes. 582 | 583 | Creating a new CancelToken per subscription allows subscriptions to cleanup their resources as soon as a subscription is closed. However this alone is not enough to eliminate all memory leaks. Depending on the implementation of CancelToken.prototype.race, this implementation may simply trade one leak for another. In the next section this problem will be explained in more detail, and an implementation of CancelToken.prototype.race will be proposed which eliminates the memory leak. 584 | 585 | ### Ensuring CancelTokens weakly reference input tokens 586 | 587 | Recall that in the previous section the polyfill of the `subscribe` implementation was modified to create a raced CancelToken from a new source token and the input token, and passed the raced token to the `subscribe` implementation: 588 | 589 | 590 | export class Observable { 591 | subscribe(observer, outerToken) { 592 | let token; 593 | // argument validation omitted... 594 | 595 | 596 | // Create new CancelToken for this Subscription operation and 597 | // link it to the Observable. 598 | const { token: innerToken, cancel } = CancelToken.source(); 599 | token = outerToken != null ? CancelToken.race([outerToken, innerToken]) : innerToken; 600 | 601 | 602 | // The cancel fn is passed to the CancelTokenObserver so that it 603 | // can cleanup subscription resources when it receives a 604 | // else or complete notification 605 | observer = new CancelTokenObserver(observer, token, cancel); 606 | token.promise.then(c => observer.catch(c)); 607 | // pass token to subscribe implementation 608 | } 609 | } 610 | 611 | 612 | Note that in order to avoid memory leaks the subscribe implementation assumes that input tokens passed to CancelToken.prototype.race weakly reference the raced token. If not there is still a memory leak, because just as there is no way to detach a Promise handler, there is also no way to unlink a raced token from its input tokens. 613 | 614 | 615 | Consider the most obvious implementation of CancelToken.prototype.race: 616 | 617 | 618 | ```js 619 | class CancelToken { 620 | // snip… 621 | static race(cancelTokens) { 622 | return new CancelToken(cancel => { 623 | Promise.race(cancelTokens.map(token => token.promise)).then(cancel); 624 | }); 625 | } 626 | } 627 | ``` 628 | 629 | 630 | Note that the implementation above will cause a reference to the raced canceltoken to be captured indirectly by all cancel tokens via the raced promise. If we assume that CancelToken uses the implementation of CancelToken.prototype.race above, then the memory leak has simply been moved rather than removed. In a long-running async operation like `flatten`, more and more tokens will be linked as each new Observables is subscribed. These references will be retained - even after the subscription has been closed. 631 | 632 | 633 | The memory leak can be eliminated if the implementation of `CancelToken.prototype.race` is modified like so: 634 | 635 | 636 | ```js 637 | class CancelToken { 638 | // snip… 639 | static race(inputTokens) { 640 | let tokenCancel; 641 | let token = new CancelToken(cancel => tokenCancel = cancel); 642 | for(let inputToken of inputTokens) { 643 | addWeakRefToLinkedCancelFunction(inputToken, tokenCancel); 644 | } 645 | return token; 646 | } 647 | } 648 | ``` 649 | 650 | 651 | When a token is cancelled, it iterates its list of weak references and forwards the `Cancel` instance to each cancel function found in the list. 652 | 653 | 654 | ```js 655 | export default class CancelToken { 656 | constructor(fn) { 657 | let promiseCancel; 658 | 659 | 660 | this.promise = new Promise((accept, reject) => { 661 | promiseCancel = accept; 662 | }); 663 | 664 | 665 | let cancel = reason => { 666 | this.reason = reason; 667 | let weakRefs = getCancelTokenWeakRefs(this); 668 | for(let weakRef of weakRefs) { 669 | let linkedCancel = getWeakRefValue(weakRef); 670 | if (linkedCancel) { 671 | linkedCancel(reason); 672 | } 673 | } 674 | clearCancelTokenWeakRefs(this); 675 | promiseCancel(reason); 676 | } 677 | 678 | 679 | fn(cancel); 680 | } 681 | 682 | 683 | // more functions snipped... 684 | } 685 | ``` 686 | 687 | This approach largely mitigates the memory leak by ensuring that raced tokens associated with completed Observable subscriptions can be collected by GC. However it's worth noting that another side effect of the implementation above is that cancellation propagates to linked tokens synchronously. This is fortuitous, because in addition to weakly referenced linked tokens, synchronous cancellation propagation is essential if the implementation of Observable on CancelTokens proposed in the document is to be viable. The next section will explain why sync cancellation propagation is necessary in order to implement Observables on CancelTokens. 688 | 689 | ### Ensuring CancelTokens synchronously propagate cancellation 690 | 691 | In the previous section we provided a naïve implementation of CancelToken.prototype.race which used promises to propagate cancellation. 692 | 693 | ```js 694 | class CancelToken { 695 | // snip… 696 | static race(cancelTokens) { 697 | return new CancelToken(cancel => { 698 | Promise.race(cancelTokens.map(token => token.promise)).then(cancel); 699 | }); 700 | } 701 | } 702 | ``` 703 | One implication of the use of Promises in the naive `race` implementation’s is that cancellation will be propagated to linked tokens _asynchronously_. Async cancellation propagation is not compatible with the CancelToken-based implementation of Observable proposed in this document. The problem is that if cancellation is _not_ propagated synchronously, notifications can be delivered to an Observer after a token has been cancelled. 704 | 705 | The following code creates an Observable that can multi-cast messages to multiple observers: 706 | 707 | ```js 708 | let capturedObservers = new Set(); 709 | let subject = new Observable((observer, token) => { 710 | capturedObservers.add(observer); 711 | token.promise.then(() => capturedObservers.delete(observer)); 712 | }); 713 | 714 | let { token, cancel } = CancelToken.source(); 715 | 716 | subject.subscribe({ 717 | next(msg) { console.log(msg); } 718 | }, token); 719 | 720 | cancel(new Cancel("Closing subscription")); 721 | 722 | for(let observer of capturedObservers) { 723 | observer.next("message"); 724 | } 725 | ``` 726 | 727 | Recall that Observable enforces the invariant that **no notification is delivered to an Observer after a subscription has been closed.** In this regard Observables match the behavior of Iterators, which never produce new values after they have completed. Note in the example above that the token is cancelled prior to any Observer being notified. Under the circumstances we would expect no console output as a result of the Observers being notified. However if we ran the code above we would observe the following console output (assuming the naive implementation of CancelToken.prototype.race): 728 | 729 | ``` 730 | message 731 | ``` 732 | 733 | In order to understand why the message is delivered to the Observer after the token passed to subscribe has been cancelled, consider the implementation of `subscribe` again: 734 | 735 | ```js 736 | export class Observable { 737 | subscribe(observer, outerToken) { 738 | let token; 739 | // argument validation omitted... 740 | 741 | // Create new CancelToken for this Subscription operation and 742 | // link it to the Observable. 743 | const { token: innerToken, cancel } = CancelToken.source(); 744 | token = outerToken != null ? CancelToken.race([outerToken, innerToken]) : innerToken; 745 | 746 | // The cancel fn is passed to the CancelTokenObserver so that it 747 | // can cleanup subscription resources when it receives a 748 | // else or complete notification 749 | observer = new CancelTokenObserver(observer, token, cancel); 750 | token.promise.then(c => observer.catch(c)); 751 | 752 | const reason = token.reason; 753 | if (reason) { 754 | return observer.catch(reason); 755 | } 756 | 757 | 758 | try { 759 | this._subscriber(observer, token); 760 | } catch(e) { 761 | observer.throw(e); 762 | } 763 | } 764 | } 765 | ``` 766 | 767 | Note that the input cancel token passed to subscribe is not passed directly to the subscribe implementation provided to the Observable constructor. Instead, a raced CancelToken is created from the input cancel token and a new cancel token created specifically for the subscription. Assuming async propagation of cancellation, the token received by the subscribe implementation will not receive the cancellation notification within the same job as cancellation was invoked. As a consequence the Observer receives all notifications dispatched in the same job in which the input token cancellation is executed. 768 | 769 | If the alternate implementation of CancelToken.prototype.race described in the previous implementation is used instead, the raced token receives the cancellation in the same job as the in in which the input token cancellation is executed. As a consequence, no console output would be received as expected. 770 | 771 | ## Observable can be implemented on CancelTokens 772 | 773 | Assuming the implementation of `CancelToken.prototype.race` proposed in this document, Observable can be implemented on CancelTokens with no loss of expressiveness. For a reference implementation, see [here](https://github.com/jhusain/proposal-observable/blob/master/src/Observable.js). 774 | --------------------------------------------------------------------------------