├── .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 |
71 | - If the observer's `error` method is called, the observer will not be invoked again and the observable's cleanup function will be called.
72 | - If the observer's `complete` method is called, the observer will not be invoked again and the observable's cleanup function will be called.
73 | - When the subscription is canceled, the observer will not be invoked again.
74 |
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 | "" +
141 | results.
142 | map(result => `- ${result}
`).
143 | reduce((html, li) => html + li) +
144 | "
";
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 | 
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 |
--------------------------------------------------------------------------------