From e74783f153d70b9a3075cdce174ecad6f167f847 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Thu, 20 Jul 2017 20:48:02 +0200 Subject: [PATCH 01/17] Change README doc for new interface using asyncIterators instead of observables * it would no more needs polyfilling Observable & uses native TS/ES7 language syntax * Refers to https://github.com/tc39/proposal-async-iteration --- README.md | 42 +++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 6bdbde7..6539680 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ const appSaga = { return model; } }, - *updater(model, action) { + async *updater(model, action) { switch (action.type) { case 'GREET_WITH_DELAY': - yield wait(1e3); // wait 1 second + await wait(1e3); // wait 1 second // dispatch GREET action to store after delay yield { type: 'GREET', message: model.message }; break; @@ -41,37 +41,36 @@ const store = createStore(appReducer, applyMiddleware(modelSaga.middleware)); ``` -### Observing continual side-effects +### Processing continual side-effects ```js -const { ObservableSubscribed } = require('aperol'); -function repeat(interval) { - return new Observable((observer) => { - const handler = setInterval(() => observer.next()); - return () => clearInterval(handler); - }); +const { AsyncIteratorStarted } = require('aperol'); +async function* repeat(interval) { + while (true) { + await wait(interval); + yield; + } } const appSaga = { reducer(model, action) { switch (action.type) { case 'GREET': return { ...model, count: model.count + 1 }; - case ObservableSubscribed: + case AsyncIteratorStarted: if (action.sourceAction.type === 'GREET_REPEATABLE') { - return { ...model, greetingSubscription: action.subscription }; + return { ...model, greetingAsyncIterator: action.asyncIterator }; } default: return model; } }, - *updater(model, action) { + async *updater(model, action) { switch (action.type) { case 'GREET_REPEATABLE': - yield repeat(1e3) // repeat every 1 second - .map(function () { + for await (let _ of repeat(1e3)) { // repeat every 1 second // dispatch GREET action to store repeatable yield { type: 'GREET' }; - }); + } break; case 'GREET': if (model.count > 10) { @@ -80,8 +79,8 @@ const appSaga = { } break; case 'STOP_GREETING': - // When no more needed subscribing side-effect greeting - model.greetingSubscription.unsubscribe(); + // When no more needed processing side-effect greeting + model.greetingAsyncIterator.return(); break; } }, @@ -107,13 +106,10 @@ const modelSaga = createModelSaga(appSaga); When you plan to use aperol in node.js on backend it should be destroyed model saga when you no more needs it for user. ```js -// It will unsubscribe all Observable subscriptions +// It will return to all async iterators modelSaga.destroy(); ``` -## Notes -*library automatically polyfill Observable if not available in global Symbol context with `zen-observable`* - ## Motivation Many other projects like `redux-saga` & simple libraries like `prism` already supports side-effects, continual processing etc. @@ -140,7 +136,7 @@ npm install aperol@next --save ``` ## Conclusion -This library was involved because there was no standardized pure-functional way how handle asynchronous side effects in redux based application. +This library was involved because there was no standardized pure-functional way how process asynchronous side effects in redux based application. Also missing standard way how to handle continual side-effects. -Aperol was inspired in some existing libraries like a [prism](https://github.com/salsita/prism) or [redux-saga](https://github.com/redux-saga/redux-saga). +Aperol was inspired in some existing libraries like a [prism](https://github.com/salsita/prism), [redux-saga](https://github.com/redux-saga/redux-saga) or [RxJS](https://github.com/Reactive-Extensions/RxJS). Aperol uses the last syntactic sugar from ES2016/TypeScript like a `async/await`, `iterator`, `asyncIterator` etc. For using is strictly recommended using transpilers like a TypeScript or Babel. From 2692fb38f900d72e7e16163a7773e9f3dbda666c Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Thu, 27 Jul 2017 19:43:17 +0200 Subject: [PATCH 02/17] Change implementation & interface of modelSaga to accept only asyncIterator instead of iterator * it will use asyncIterator even instead of observables for continual side effects * promises are ready to use await instead of yielding promises in updater --- mocha.opts | 1 + package-lock.json | 17 ----- package.json | 4 +- src/AsyncIteratorStarted.ts | 12 ++++ src/ISaga.ts | 3 +- src/IUpdaterYield.ts | 16 ----- src/ObservableSubscribed.ts | 12 ---- src/combineSagas.ts | 27 +++----- src/createModelSaga.ts | 82 +++++++++++------------- src/index.ts | 7 +-- src/observable-polyfill.ts | 30 --------- src/polyfill/asyncIterator.ts | 23 +++++++ tests/integration/index.spec.ts | 10 +-- tests/tsconfig.json | 4 +- tests/unit/combineSagas.spec.ts | 19 +++--- tests/unit/createModelSaga.spec.ts | 58 +++++++++-------- tests/unit/sumModelMock.ts | 29 +++++---- tsconfig.json | 4 +- types/lib.es2017.observable/index.d.ts | 87 -------------------------- types/zen-observable/index.d.ts | 6 -- 20 files changed, 154 insertions(+), 297 deletions(-) create mode 100644 src/AsyncIteratorStarted.ts delete mode 100644 src/IUpdaterYield.ts delete mode 100644 src/ObservableSubscribed.ts delete mode 100644 src/observable-polyfill.ts create mode 100644 src/polyfill/asyncIterator.ts delete mode 100644 types/lib.es2017.observable/index.d.ts delete mode 100644 types/zen-observable/index.d.ts diff --git a/mocha.opts b/mocha.opts index a9ae25a..fcd2d2a 100644 --- a/mocha.opts +++ b/mocha.opts @@ -1,4 +1,5 @@ --require ts-node/register +--require ./src/polyfill/asyncIterator.ts --ui bdd --watch-extensions ts tests/**/*.spec.ts diff --git a/package-lock.json b/package-lock.json index de8f44d..ca9f583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -601,17 +601,6 @@ "babel-types": "6.25.0" } }, - "babel-polyfill": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.23.0.tgz", - "integrity": "sha1-g2TKYt+Or7gwSZ9pkXdGbDsDSZ0=", - "dev": true, - "requires": { - "babel-runtime": "6.23.0", - "core-js": "2.4.1", - "regenerator-runtime": "0.10.5" - } - }, "babel-preset-es2015": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz", @@ -2652,12 +2641,6 @@ "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", "integrity": "sha1-5a2ryKz0CPY4X8dklWhMiOavaJo=", "dev": true - }, - "zen-observable": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.5.2.tgz", - "integrity": "sha512-Dhp/R0pqSHj3vPs5O1gVd9kZx5Iew2lqVcfJQOBHx3llM/dLea8vl9wSa9FK8wLdSBQJ6mmgKi9+Rk2DRH3i9Q==", - "dev": true } } } diff --git a/package.json b/package.json index 3796203..6e9b92b 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "@types/should": "^8.3.0", "babel-core": "^6.25.0", "babel-loader": "^7.1.1", - "babel-polyfill": "^6.23.0", "babel-preset-es2015": "^6.24.1", "json-loader": "^0.5.4", "mocha": "^3.4.2", @@ -60,7 +59,6 @@ "ts-node": "^3.2.0", "tslint": "^5.5.0", "typescript": "^2.4.1", - "webpack": "^1.15.0", - "zen-observable": "^0.5.2" + "webpack": "^1.15.0" } } diff --git a/src/AsyncIteratorStarted.ts b/src/AsyncIteratorStarted.ts new file mode 100644 index 0000000..4b5cc8a --- /dev/null +++ b/src/AsyncIteratorStarted.ts @@ -0,0 +1,12 @@ + +import { Action } from 'redux'; + +export type AsyncIteratorStartedType = '@@aperol/AsyncIteratorStarted'; +export const AsyncIteratorStarted: AsyncIteratorStartedType = '@@aperol/AsyncIteratorStarted'; +export interface AsyncIteratorStarted { + type: AsyncIteratorStartedType; + asyncIterator: AsyncIterableIterator; + promise: Promise; + sourceAction: TSourceAction; +} +export default AsyncIteratorStarted; diff --git a/src/ISaga.ts b/src/ISaga.ts index cdd035c..cfdccc3 100644 --- a/src/ISaga.ts +++ b/src/ISaga.ts @@ -1,9 +1,8 @@ import { Action } from 'redux'; -import IUpdaterYield from './IUpdaterYield'; interface ISaga { reducer(model: TModel, action: Action): TModel; - updater(model: TModel, action: Action): Iterator; + updater(model: TModel, action: Action): AsyncIterableIterator; } export default ISaga; diff --git a/src/IUpdaterYield.ts b/src/IUpdaterYield.ts deleted file mode 100644 index 97ffb7f..0000000 --- a/src/IUpdaterYield.ts +++ /dev/null @@ -1,16 +0,0 @@ - -import { Action } from 'redux'; - -// yielding of Observable could by recursive. However typescript could not describe it -export type ISub2UpdaterYield = Promise - | Observable, Error> - | Action; - -export type ISubUpdaterYield = Promise - | Observable, Error> - | Action; - -type IUpdaterYield = Promise - | Observable, Error> - | Action; -export default IUpdaterYield; diff --git a/src/ObservableSubscribed.ts b/src/ObservableSubscribed.ts deleted file mode 100644 index 916987c..0000000 --- a/src/ObservableSubscribed.ts +++ /dev/null @@ -1,12 +0,0 @@ - -import { Action } from 'redux'; - -export type ObservableSubscribedType = '@@aperol/ObservableSubscribed'; -export const ObservableSubscribed: ObservableSubscribedType = '@@aperol/ObservableSubscribed'; -export interface ObservableSubscribed { - type: ObservableSubscribedType; - observable: Observable; - subscription: Subscription; - sourceAction: TSourceAction; -} -export default ObservableSubscribed; diff --git a/src/combineSagas.ts b/src/combineSagas.ts index 541997c..c53cc72 100644 --- a/src/combineSagas.ts +++ b/src/combineSagas.ts @@ -2,20 +2,22 @@ import { combineReducers, ReducersMapObject } from 'redux'; import ISaga from './ISaga'; import { Action } from 'redux'; -import IUpdaterYield from './IUpdaterYield'; -export interface ISagasMapObject { - [key: string]: ISaga; -} +export type ISagasMapObject< + TModel extends { [ModelKey in TModelKey]: any }, + TModelKey extends keyof TModel +> = { + [ModelKey in TModelKey]: ISaga; +}; export interface ICombinedModel { [key: string]: any; } export default function combineSagas( - sagas: ISagasMapObject + sagas: ISagasMapObject ): ISaga { - const sagaKeys = Object.keys(sagas); + const sagaKeys: (keyof TModel)[] = Object.keys(sagas); const reducer = combineReducers(sagaKeys.reduce( (reducers: ReducersMapObject, key: string) => { const saga = sagas[key]; @@ -24,18 +26,9 @@ export default function combineSagas( }, {} )); - const updater = function* (model: TModel, action: Action) { + const updater = async function* (model: TModel, action: Action) { for (let key of sagaKeys) { - const saga = sagas[key]; - const iterator = saga.updater(model[key], action); - let nextResult; - do { - let item: IteratorResult = iterator.next(nextResult); - if (item.done) { - break; - } - nextResult = yield item.value; - } while (true); + yield* sagas[key].updater(model[key], action); } }; return { diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index 46f8770..a83b471 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -2,82 +2,76 @@ import { createStore, Store, Middleware, Dispatch, Action } from 'redux'; import IPromiseAction from './IPromiseAction'; import ISaga from './ISaga'; -import IUpdaterYield from './IUpdaterYield'; -import ObservableSubscribed from './ObservableSubscribed'; +import AsyncIteratorStarted from './AsyncIteratorStarted'; async function update( - subscriptions: Subscription[], - iterator: Iterator, + asyncIterator: AsyncIterableIterator, dispatch: Dispatch, - sourceAction: Action ) { - let nextResult; - do { - let item: IteratorResult = iterator.next(nextResult); - nextResult = undefined; - if (item.done) { - break; - } else - if (item.value instanceof Promise) { - nextResult = await item.value; - } else - if (item.value instanceof Observable) { - const observable = item.value; - const subscription = observable.subscribe(function (observableIterator: Iterator) { - update(subscriptions, observableIterator, dispatch, sourceAction); - }); - subscriptions.push(subscription); - const promiseObservableSubscribed = dispatch({ - type: ObservableSubscribed, - observable, - subscription, - sourceAction, - } as ObservableSubscribed) as Action as IPromiseAction; - if (promiseObservableSubscribed.__promise instanceof Promise) { - await promiseObservableSubscribed.__promise; - } - } else - if (typeof (item.value as Action).type !== 'undefined') { - const promiseAction = dispatch(item.value) as IPromiseAction; + for await (let value of asyncIterator) { + if (typeof value === 'object') { + const promiseAction = dispatch(value) as IPromiseAction; if (promiseAction.__promise instanceof Promise) { await promiseAction.__promise; } } else { const error = new Error( - 'Updater must yield action or promise. ' - + JSON.stringify(item.value) + ' given.' + 'Updater must yield action. ' + JSON.stringify(value) + ' given.' ); - if (iterator.throw) { - iterator.throw(error); + if (asyncIterator.throw) { + asyncIterator.throw(error); } else { throw error; } } - } while (true); + } } export default function createModelSaga(saga: ISaga) { const sagaStore = createStore(saga.reducer); - const subscriptions: Subscription[] = []; - const middleware: Middleware = (store: Store) => (nextDispatch: Dispatch) => (action: any) => { + const asyncIterators: { [asyncIteratorUid: string]: AsyncIterableIterator } = {}; + const middleware: Middleware = (store: Store) => (nextDispatch: Dispatch) => (action: TAction) => { const result = nextDispatch(action); sagaStore.dispatch(action); const model = sagaStore.getState(); - const iterator = saga.updater(model, action); - const promise = update(subscriptions, iterator, store.dispatch, action); + const asyncIterator = saga.updater(model, action); + const asyncIteratorUid = generateUid(); + asyncIterators[asyncIteratorUid] = asyncIterator; + const promise = update(asyncIterator, store.dispatch) + .then(() => { + delete asyncIterators[asyncIteratorUid]; + }); + if (action.type !== AsyncIteratorStarted) { + store.dispatch({ + type: AsyncIteratorStarted, + asyncIterator, + promise, + sourceAction: action, + } as AsyncIteratorStarted); + } Object.defineProperty(result, '__promise', { enumerable: false, configurable: false, writable: false, value: promise }); - return result as any; + return result; }; const destroy = () => { - subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe()); + for (let asyncIteratorUid in asyncIterators) { + const asyncIterator = asyncIterators[asyncIteratorUid]; + delete asyncIterators[asyncIteratorUid]; + if (asyncIterator.return) { + asyncIterator.return(); + } + } }; return { middleware, destroy }; } + +function generateUid() { + return Math.random().toString().substr(2); +} diff --git a/src/index.ts b/src/index.ts index 7893a5e..0227bc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,7 @@ -import './observable-polyfill'; -import 'babel-polyfill'; - export { default as createModelSaga } from './createModelSaga'; export { default as combineSagas } from './combineSagas'; -export { default as ObservableSubscribed } from './ObservableSubscribed'; +export { default as AsyncIteratorStarted } from './AsyncIteratorStarted'; export { default as IPromiseAction } from './IPromiseAction'; export { default as ISaga } from './ISaga'; -export { default as IUpdaterYield } from './IUpdaterYield'; +export { Action as IAction } from 'redux'; diff --git a/src/observable-polyfill.ts b/src/observable-polyfill.ts deleted file mode 100644 index e6e4ad0..0000000 --- a/src/observable-polyfill.ts +++ /dev/null @@ -1,30 +0,0 @@ - -/// -/// - -import * as Observable from 'zen-observable'; - -let root; - -if (typeof self !== 'undefined') { - root = self; -} else if (typeof window !== 'undefined') { - root = window; -} else if (typeof global !== 'undefined') { - root = global; -} else if (typeof module !== 'undefined') { - root = module; -} else { - root = Function('return this')(); -} - -if (typeof root.Observable === 'undefined') { - root.Observable = Observable; - (Symbol as any).observable = Observable; -} -if (typeof root.Observable.prototype.map === 'undefined') { - root.Observable.prototype.map = Observable.prototype.map; -} -if (typeof root.Observable.prototype.filter === 'undefined') { - root.Observable.prototype.filter = Observable.prototype.filter; -} diff --git a/src/polyfill/asyncIterator.ts b/src/polyfill/asyncIterator.ts new file mode 100644 index 0000000..2f18a48 --- /dev/null +++ b/src/polyfill/asyncIterator.ts @@ -0,0 +1,23 @@ + +export default function* asyncIterator(collection: Promise): IterableIterator> { + let length: number = 1; + collection = collection.then(function(items: T[]): T[] { + if (items) { + length = items.length; + } + + return items; + }); + + for (let i: number = 0; i < length; i++) { + yield collection.then((items: T[]): T | undefined => { + if (!items) { + return undefined; + } + return items[i]; + }); + + } +} + +(Symbol as any).asyncIterator = Symbol.asyncIterator || asyncIterator; diff --git a/tests/integration/index.spec.ts b/tests/integration/index.spec.ts index 91904a8..03301e5 100644 --- a/tests/integration/index.spec.ts +++ b/tests/integration/index.spec.ts @@ -2,7 +2,7 @@ import * as should from 'should'; import createModelSaga from '../../src/createModelSaga'; import combineSagas from '../../src/combineSagas'; -import ObservableSubscribed from '../../src/ObservableSubscribed'; +import AsyncIteratorStarted from '../../src/AsyncIteratorStarted'; describe('index', () => { @@ -10,19 +10,19 @@ describe('index', () => { const { createModelSaga: actualCreateModelSaga, combineSagas: actualCombineSagas, - ObservableSubscribed: actualObservableSubscribed, + AsyncIteratorStarted: actualAsyncIteratorStarted, } = require('../../src/index'); should(actualCreateModelSaga).ok(); should(actualCombineSagas).ok(); - should(actualObservableSubscribed).ok(); + should(actualAsyncIteratorStarted).ok(); should(actualCreateModelSaga).Function(); should(actualCombineSagas).Function(); - should(actualObservableSubscribed).String(); + should(actualAsyncIteratorStarted).String(); should(actualCreateModelSaga).equal(createModelSaga); should(actualCombineSagas).equal(combineSagas); - should(actualObservableSubscribed).equal(ObservableSubscribed); + should(actualAsyncIteratorStarted).equal(AsyncIteratorStarted); }); }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 3ee6954..36c7be1 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -14,9 +14,9 @@ "noUnusedParameters": true, "noUnusedLocals": true, "typeRoots": [ - "../types/", "../node_modules/@types/" - ] + ], + "lib": ["esnext"] }, "include": [ "../src/**/*.ts", diff --git a/tests/unit/combineSagas.spec.ts b/tests/unit/combineSagas.spec.ts index 1397faa..61cfbd7 100644 --- a/tests/unit/combineSagas.spec.ts +++ b/tests/unit/combineSagas.spec.ts @@ -1,6 +1,4 @@ -import '../../src/observable-polyfill'; -import 'babel-polyfill'; import { createStore, applyMiddleware, Action } from 'redux'; import * as should from 'should'; import { @@ -45,11 +43,11 @@ describe('Application.combineSaga', function () { return model; } }, - *updater(model: IWarningModel, action: ISubtract) { + async *updater(model: IWarningModel, action: ISubtract) { switch (action.type) { case 'Subtract': if (model.length % 2 === 0) { - yield alertAllWarnings(); + await alertAllWarnings(); yield { type: 'WarningShown', } as IWarningShown; @@ -69,7 +67,7 @@ describe('Application.combineSaga', function () { shownWarningsCount = 0; }); - it('should combine sagas deep structure', function* () { + it('should combine sagas deep structure', async function () { const appSaga = combineSagas({ math: combineSagas({ sum: sumSaga, @@ -94,12 +92,12 @@ describe('Application.combineSaga', function () { type: 'WarningShown', } as IWarningShown; const promiseAdd113 = store.dispatch(add113) as Action as IPromiseAction; - yield promiseAdd113.__promise; + await promiseAdd113.__promise; const promiseSubtract112 = store.dispatch(subtract112) as Action as IPromiseAction; - yield promiseSubtract112.__promise; + await promiseSubtract112.__promise; const secondSubtract112 = { ...subtract112 } as Action; const promiseSubtract112Again = store.dispatch(secondSubtract112) as Action as IPromiseAction; - yield promiseSubtract112Again.__promise; + await promiseSubtract112Again.__promise; should.deepEqual(removeInternalActions(assertations.reducedActions), [ add113, added, @@ -124,9 +122,14 @@ describe('Application.combineSaga', function () { should.deepEqual(assertations.updatedModels, [ { sum: 113 }, { sum: 113 }, + { sum: 113 }, + { sum: 113 }, + { sum: 1 }, { sum: 1 }, { sum: -111 }, { sum: -111 }, + { sum: -111 }, + { sum: -111 }, ]); should.deepEqual(assertations.addedAmounts, [ 113, diff --git a/tests/unit/createModelSaga.spec.ts b/tests/unit/createModelSaga.spec.ts index a53fce5..dae450d 100644 --- a/tests/unit/createModelSaga.spec.ts +++ b/tests/unit/createModelSaga.spec.ts @@ -1,10 +1,7 @@ -import '../../src/observable-polyfill'; -import 'babel-polyfill'; import { createStore, applyMiddleware, Action } from 'redux'; import * as should from 'should'; import { - reduxInit, removeInternalActions, assertations, sumSaga, @@ -14,7 +11,7 @@ import { } from './sumModelMock'; import createModelSaga from '../../src/createModelSaga'; import IPromiseAction from '../../src/IPromiseAction'; -import ObservableSubscribed from '../../src/ObservableSubscribed'; +import AsyncIteratorStarted from '../../src/AsyncIteratorStarted'; describe('Application.craeteModelSaga', function () { @@ -26,7 +23,7 @@ describe('Application.craeteModelSaga', function () { assertations.dispatchedActions = []; }); - it('should reduce action & then apply async updater', function* () { + it('should reduce action & then apply async updater', async function () { const modelSaga = createModelSaga(sumSaga); const store = createStore(sumReducer, applyMiddleware(modelSaga.middleware)); const add113 = { @@ -38,31 +35,31 @@ describe('Application.craeteModelSaga', function () { uid: 'new-uid', }; const promiseAction = store.dispatch(add113) as Action as IPromiseAction; - yield promiseAction.__promise; - should.deepEqual(assertations.reducedActions, [ - reduxInit, // init redux in saga + await promiseAction.__promise; + should.deepEqual(removeInternalActions(assertations.reducedActions), [ add113, added, ]); - should.deepEqual(assertations.updatedActions, [ + should.deepEqual(removeInternalActions(assertations.updatedActions), [ add113, added, ]); - should.deepEqual(assertations.dispatchedActions, [ - reduxInit, // init redux in saga + should.deepEqual(removeInternalActions(assertations.dispatchedActions), [ add113, added, ]); should.deepEqual(assertations.updatedModels, [ { sum: 113 }, { sum: 113 }, + { sum: 113 }, + { sum: 113 }, ]); should.deepEqual(assertations.addedAmounts, [ 113, ]); }); - it('should reduce action & then apply async updater with yielded observable', function* () { + it('should reduce action & then apply async updater with continual side-effect', async function () { const modelSaga = createModelSaga(sumSaga); const store = createStore(sumReducer, applyMiddleware(modelSaga.middleware)); const autoAdding113 = { @@ -73,19 +70,18 @@ describe('Application.craeteModelSaga', function () { type: 'Added', uid: 'new-uid', }; - const promiseAction = store.dispatch(autoAdding113) as Action as IPromiseAction; - yield promiseAction.__promise; + store.dispatch(autoAdding113); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, ]); autoAdding113.__doAdd!(); - yield new Promise((resolve: () => void) => setTimeout(resolve, 2)); + await new Promise((resolve: () => void) => setTimeout(resolve, 2)); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, added, ]); autoAdding113.__doAdd!(); - yield new Promise((resolve: () => void) => setTimeout(resolve, 2)); + await new Promise((resolve: () => void) => setTimeout(resolve, 2)); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, added, @@ -93,7 +89,7 @@ describe('Application.craeteModelSaga', function () { ]); }); - it('should unsubscribe all yielded observables after destroy', function* () { + it('should return all async iterators after destroy', async function () { const modelSaga = createModelSaga(sumSaga); const store = createStore(sumReducer, applyMiddleware(modelSaga.middleware)); const autoAdding113 = { @@ -104,22 +100,30 @@ describe('Application.craeteModelSaga', function () { type: 'Added', uid: 'new-uid', }; - const promiseAction = store.dispatch(autoAdding113) as Action as IPromiseAction; - yield promiseAction.__promise; + store.dispatch(autoAdding113); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, ]); autoAdding113.__doAdd!(); - yield new Promise((resolve: () => void) => setTimeout(resolve, 2)); + await new Promise((resolve: () => void) => setTimeout(resolve, 2)); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, added, ]); modelSaga.destroy(); - should.strictEqual(autoAdding113.__doAdd, null); + // Because of polyfilled asyncIterator must be last promise resolved to correctly close iterator + // In native asyncIterator it is not waiting for promise & return (throw) immediatelly + // According to https://github.com/bergus/promise-cancellation/blob/master/API.md it is maybe not possible now + autoAdding113.__doAdd!(); + await new Promise((resolve: () => void) => setTimeout(resolve, 2)); + const autoAddingAsyncIteratorStartedAction = assertations.dispatchedActions! + .find((action: AsyncIteratorStarted) => action.type === AsyncIteratorStarted && action.sourceAction === autoAdding113); + should(autoAddingAsyncIteratorStartedAction).not.empty(); + const nextValue = await (autoAddingAsyncIteratorStartedAction as AsyncIteratorStarted).asyncIterator.next(); + should(nextValue.done).true(); }); - it('should dispatch ObservableSubscribed action when yielded observable', function* () { + it('should dispatch AsyncIteratorStarted action always when iterator started', async function () { const modelSaga = createModelSaga(sumSaga); const store = createStore(sumReducer, applyMiddleware(modelSaga.middleware)); const autoAdding113 = { @@ -127,9 +131,11 @@ describe('Application.craeteModelSaga', function () { amount: 113, } as IAutoAdding; const promiseAction = store.dispatch(autoAdding113) as Action as IPromiseAction; - yield promiseAction.__promise; - const observableSubscribed = assertations.reducedActions! - .find((action: Action) => action.type === ObservableSubscribed); - should.notStrictEqual(observableSubscribed, undefined); + const asyncIteratorStarted = assertations.reducedActions! + .find((action: Action) => action.type === AsyncIteratorStarted) as AsyncIteratorStarted; + should.notStrictEqual(asyncIteratorStarted, undefined); + should(asyncIteratorStarted.type).equal(AsyncIteratorStarted); + should(asyncIteratorStarted.promise).equal(promiseAction.__promise); + should(asyncIteratorStarted.sourceAction).equal(autoAdding113); }); }); diff --git a/tests/unit/sumModelMock.ts b/tests/unit/sumModelMock.ts index 4c762dc..2e5f2d0 100644 --- a/tests/unit/sumModelMock.ts +++ b/tests/unit/sumModelMock.ts @@ -1,7 +1,7 @@ import * as should from 'should'; import { Action } from 'redux'; -import ObservableSubscribed from '../../src/ObservableSubscribed'; +import AsyncIteratorStarted from '../../src/AsyncIteratorStarted'; export interface ISumModel { sum: number; @@ -37,12 +37,10 @@ export const initialSumModel = { sum: 0, }; -export const reduxInit = { type: '@@redux/INIT' }; - export function removeInternalActions(actions?: Action[]) { return actions! .filter((action: Action) => action.type.indexOf('@@redux/') !== 0) - .filter((action: Action) => action.type !== ObservableSubscribed); + .filter((action: Action) => action.type !== AsyncIteratorStarted); } export const assertations: { @@ -84,31 +82,32 @@ export const sumSaga = { return model; } }, - *updater(model: ISumModel, action: IAdd | IAutoAdding) { + async *updater(model: ISumModel, action: IAdd | IAutoAdding) { assertations.updatedActions!.push(action); assertations.updatedModels!.push(model); switch (action.type) { case 'Add': - const uid = yield addAmount(action.amount); + const uid = await addAmount(action.amount); yield { type: 'Added', uid, } as IAdded; break; case 'AutoAdding': - const observable = new Observable((observer: SubscriptionObserver) => { - action.__doAdd = () => observer.next(action.amount); - return () => { - action.__doAdd = null; - }; - }); - yield observable.map(function* (amount: number) { - const autoUid = yield addAmount(amount); + async function* autoAdding() { + while (true) { + yield await new Promise((resolve: (amount: number) => void) => { + (action as IAutoAdding).__doAdd = () => resolve(action.amount); + }); + } + } + for await (let amount of autoAdding()) { + const autoUid = await addAmount(amount); yield { type: 'Added', uid: autoUid, } as IAdded; - }); + } break; default: } diff --git a/tsconfig.json b/tsconfig.json index 2bd4f1e..d5d8fd6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,9 +16,9 @@ "noUnusedParameters": true, "noUnusedLocals": true, "typeRoots": [ - "types/", "node_modules/@types/" - ] + ], + "lib": ["esnext"] }, "include": [ "src/**/*.ts" diff --git a/types/lib.es2017.observable/index.d.ts b/types/lib.es2017.observable/index.d.ts deleted file mode 100644 index f298d6b..0000000 --- a/types/lib.es2017.observable/index.d.ts +++ /dev/null @@ -1,87 +0,0 @@ - -interface SymbolConstructor { - readonly observable: symbol; -} - -declare interface ObservableConstructor { - new(subscriber: SubscriberFunction): Observable; -} - -declare class Observable { - - // Converts items to an Observable - public static of(...items: TValue[]): Observable; - - // Converts an observable or iterable to an Observable - public static from( - observable: Observable | IterableIterator - ): Observable; - - constructor(subscriber: SubscriberFunction); - - // Subscribes to the sequence with an observer - public subscribe(observer: Observer): Subscription; - - public map(mapFunction: (value: TValue) => TMappedValue): Observable; - public forEach(eachFunction: (value: TValue) => void): Promise; - public filter(filterFunction: (value: TValue) => boolean): Observable; - public reduce( - reduceFunction: (value: TValue) => TReducetion, - initialReduction?: TReducetion - ): Observable; - public flatMap( - mapFunction: (value: TValue | Observable) => TMappedValue - ): Observable; - - // Subscribes to the sequence with callbacks - public subscribe( - onNext: (value: TValue) => void, - onError?: (error: TError) => void, - onComplete?: () => void - ): Subscription; - - // Returns itself - public [Symbol.observable](): Observable; -} - -interface Subscription { - - // Cancels the subscription - unsubscribe(): void; - - // A boolean value indicating whether the subscription is closed - closed(): boolean; -} - -interface SubscriberFunction { - (observer: SubscriptionObserver): (() => void) | Subscription; -} - -interface Observer { - // Receives the subscription object when `subscribe` is called - start(subscription: Subscription): void; - - // Receives the next value in the sequence - next(value: TValue): void; - - // Receives the sequence error - error(errorValue: TError): void; - - // Receives a completion notification - complete(): void; -} - -interface SubscriptionObserver { - - // Sends the next value in the sequence - next(value: TValue): void; - - // Sends the sequence error - error(errorValue: TError): void; - - // Sends the completion notification - complete(): void; - - // A boolean value indicating whether the subscription is closed - closed(): boolean; -} diff --git a/types/zen-observable/index.d.ts b/types/zen-observable/index.d.ts deleted file mode 100644 index c5a3eba..0000000 --- a/types/zen-observable/index.d.ts +++ /dev/null @@ -1,6 +0,0 @@ - -declare module 'zen-observable' { - namespace zenObservable {} - let zenObservable: ObservableConstructor; - export = zenObservable; -} From 5fa2b75093e6b554e6bfda145c337ed19f9c815b Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Thu, 27 Jul 2017 20:06:51 +0200 Subject: [PATCH 03/17] Add polyfill asyncIterator to exported functions --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 0227bc7..3b3d319 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,4 @@ export { default as AsyncIteratorStarted } from './AsyncIteratorStarted'; export { default as IPromiseAction } from './IPromiseAction'; export { default as ISaga } from './ISaga'; export { Action as IAction } from 'redux'; +export function polyfillAsyncIterator() { require('./polyfill/asyncIterator'); } From 543be316336fad867346ccf4f9af5d967c9e236b Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Thu, 27 Jul 2017 20:22:22 +0200 Subject: [PATCH 04/17] Bump version --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca9f583..6605785 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "aperol", - "version": "1.0.3", + "version": "2.0.0-next.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 6e9b92b..1647ff3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aperol", - "version": "1.0.3", + "version": "2.0.0-next.2", "description": "JS library for asynchronous processing of side effects in action based application.", "main": "dist/index.js", "typings": "dist/index.d.ts", From ad85dba89d3d327f7753301343892b4f7a78fbd0 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Mon, 31 Jul 2017 21:30:31 +0200 Subject: [PATCH 05/17] Describe polyfill of asyncIterator closes #3 --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 6539680..9095eec 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,13 @@ modelSaga.destroy(); ``` +### Polyfill +For running library in old browsers & non harmony flagged Node.js is necessary to polyfill `Symbol.asyncIterator`. You can achieve it by simple implementation included in library. +```js +require('aperol').polyfillAsyncIterator(); +``` + + ## Motivation Many other projects like `redux-saga` & simple libraries like `prism` already supports side-effects, continual processing etc. However there are some deal breakers which motivates me to write self library. Here are the main points: From eaf161babb3d347d2c50717911efb27fda2c1d1e Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Tue, 1 Aug 2017 10:15:19 +0200 Subject: [PATCH 06/17] Describe model-less saga --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 9095eec..d9fa6a5 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,20 @@ modelSaga.destroy(); ``` +### Model-less saga +*Analogy to stateless components in React* +```js +async function* appSaga(action) { + switch (action.type) { + case 'GREET_WITH_DELAY': + await wait(1e3); + yield { type: 'GREET' }; + break; + } +} +``` + + ### Polyfill For running library in old browsers & non harmony flagged Node.js is necessary to polyfill `Symbol.asyncIterator`. You can achieve it by simple implementation included in library. ```js From e486b907967d0a4638fde0b6fdd922a913146040 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Tue, 1 Aug 2017 10:17:50 +0200 Subject: [PATCH 07/17] Describe annotated continual side effects --- README.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d9fa6a5..d4c485c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ const store = createStore(appReducer, applyMiddleware(modelSaga.middleware)); ### Processing continual side-effects ```js -const { AsyncIteratorStarted } = require('aperol'); +const { AsyncIteratorStarted, runItContinual } = require('aperol'); async function* repeat(interval) { while (true) { await wait(interval); @@ -67,10 +67,12 @@ const appSaga = { async *updater(model, action) { switch (action.type) { case 'GREET_REPEATABLE': - for await (let _ of repeat(1e3)) { // repeat every 1 second - // dispatch GREET action to store repeatable - yield { type: 'GREET' }; - } + yield runItContinual(async function* () { + for await (let _ of repeat(1e3)) { // repeat every 1 second + // dispatch GREET action to store repeatable + yield { type: 'GREET' }; + } + }); break; case 'GREET': if (model.count > 10) { @@ -80,7 +82,7 @@ const appSaga = { break; case 'STOP_GREETING': // When no more needed processing side-effect greeting - model.greetingAsyncIterator.return(); + await model.greetingAsyncIterator.return(); break; } }, @@ -102,15 +104,6 @@ const modelSaga = createModelSaga(appSaga); ``` -### Destroy for backend -When you plan to use aperol in node.js on backend it should be destroyed model saga when you no more needs it for user. - -```js -// It will return to all async iterators -modelSaga.destroy(); -``` - - ### Model-less saga *Analogy to stateless components in React* ```js From f33dc4ded6081fe9dadbad7988e8e1846b358a61 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Tue, 1 Aug 2017 10:17:14 +0200 Subject: [PATCH 08/17] Describe server-side rendering without continual side-effects --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index d4c485c..6b5822a 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,25 @@ async function* appSaga(action) { } ``` +### Server side rendering + +```jsx +const { createModelSaga } = require('aperol'); +const appReducer = require('./appReducer'); +const appSaga = require('./appSaga'); +handleGetRequest(async (request) => { + const modelSaga = createModelSaga(appSaga, { + // continual effects are not wanted to wait for + // do not run any continual generator & return to all continual async iterators + skipContinual: true, + }); + const store = createStore(appReducer, applyMiddleware(modelSaga.middleware)); + // Handle request by yourself & wait for updaters are done + await store.dispatch({ type: 'HANDLE_REQUEST', request }).__promise; + const html = renderToString(); + return html; +}); +``` ### Polyfill For running library in old browsers & non harmony flagged Node.js is necessary to polyfill `Symbol.asyncIterator`. You can achieve it by simple implementation included in library. From 6349499f743f0b6fb230b97b257132b42045633a Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 14:05:24 +0200 Subject: [PATCH 09/17] Make PromiseAction only extending for __promise property without I prefix of interface --- src/IPromiseAction.ts | 7 ------- src/createModelSaga.ts | 6 ++++-- src/index.ts | 2 +- src/internalActions.ts | 5 +++++ tests/unit/combineSagas.spec.ts | 8 ++++---- tests/unit/createModelSaga.spec.ts | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) delete mode 100644 src/IPromiseAction.ts create mode 100644 src/internalActions.ts diff --git a/src/IPromiseAction.ts b/src/IPromiseAction.ts deleted file mode 100644 index 6bd8f48..0000000 --- a/src/IPromiseAction.ts +++ /dev/null @@ -1,7 +0,0 @@ - -import { Action } from 'redux'; - -interface IPromiseAction extends Action { - __promise: Promise; -} -export default IPromiseAction; diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index a83b471..11a9e59 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -1,8 +1,10 @@ import { createStore, Store, Middleware, Dispatch, Action } from 'redux'; -import IPromiseAction from './IPromiseAction'; import ISaga from './ISaga'; import AsyncIteratorStarted from './AsyncIteratorStarted'; +import { + PromiseAction, +} from './internalActions'; async function update( asyncIterator: AsyncIterableIterator, @@ -10,7 +12,7 @@ async function update( ) { for await (let value of asyncIterator) { if (typeof value === 'object') { - const promiseAction = dispatch(value) as IPromiseAction; + const promiseAction = dispatch(value) as PromiseAction & Action; if (promiseAction.__promise instanceof Promise) { await promiseAction.__promise; } diff --git a/src/index.ts b/src/index.ts index 3b3d319..249970e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export { default as createModelSaga } from './createModelSaga'; export { default as combineSagas } from './combineSagas'; export { default as AsyncIteratorStarted } from './AsyncIteratorStarted'; -export { default as IPromiseAction } from './IPromiseAction'; +export { PromiseAction } from './internalActions'; export { default as ISaga } from './ISaga'; export { Action as IAction } from 'redux'; export function polyfillAsyncIterator() { require('./polyfill/asyncIterator'); } diff --git a/src/internalActions.ts b/src/internalActions.ts new file mode 100644 index 0000000..458980a --- /dev/null +++ b/src/internalActions.ts @@ -0,0 +1,5 @@ + +export const promiseProperty = '__promise'; +export interface PromiseAction { + '__promise': Promise; +} diff --git a/tests/unit/combineSagas.spec.ts b/tests/unit/combineSagas.spec.ts index 61cfbd7..46c7975 100644 --- a/tests/unit/combineSagas.spec.ts +++ b/tests/unit/combineSagas.spec.ts @@ -12,7 +12,7 @@ import { } from './sumModelMock'; import createModelSaga from '../../src/createModelSaga'; import combineSagas from '../../src/combineSagas'; -import IPromiseAction from '../../src/IPromiseAction'; +import { PromiseAction } from '../../src/internalActions'; describe('Application.combineSaga', function () { @@ -91,12 +91,12 @@ describe('Application.combineSaga', function () { const warningShown = { type: 'WarningShown', } as IWarningShown; - const promiseAdd113 = store.dispatch(add113) as Action as IPromiseAction; + const promiseAdd113 = store.dispatch(add113) as Action as Action & PromiseAction; await promiseAdd113.__promise; - const promiseSubtract112 = store.dispatch(subtract112) as Action as IPromiseAction; + const promiseSubtract112 = store.dispatch(subtract112) as Action as Action & PromiseAction; await promiseSubtract112.__promise; const secondSubtract112 = { ...subtract112 } as Action; - const promiseSubtract112Again = store.dispatch(secondSubtract112) as Action as IPromiseAction; + const promiseSubtract112Again = store.dispatch(secondSubtract112) as Action & PromiseAction; await promiseSubtract112Again.__promise; should.deepEqual(removeInternalActions(assertations.reducedActions), [ add113, diff --git a/tests/unit/createModelSaga.spec.ts b/tests/unit/createModelSaga.spec.ts index dae450d..cebb3e9 100644 --- a/tests/unit/createModelSaga.spec.ts +++ b/tests/unit/createModelSaga.spec.ts @@ -10,7 +10,7 @@ import { IAutoAdding, } from './sumModelMock'; import createModelSaga from '../../src/createModelSaga'; -import IPromiseAction from '../../src/IPromiseAction'; +import { PromiseAction } from '../../src/internalActions'; import AsyncIteratorStarted from '../../src/AsyncIteratorStarted'; describe('Application.craeteModelSaga', function () { @@ -34,7 +34,7 @@ describe('Application.craeteModelSaga', function () { type: 'Added', uid: 'new-uid', }; - const promiseAction = store.dispatch(add113) as Action as IPromiseAction; + const promiseAction = store.dispatch(add113) as Action as Action & PromiseAction; await promiseAction.__promise; should.deepEqual(removeInternalActions(assertations.reducedActions), [ add113, @@ -130,7 +130,7 @@ describe('Application.craeteModelSaga', function () { type: 'AutoAdding', amount: 113, } as IAutoAdding; - const promiseAction = store.dispatch(autoAdding113) as Action as IPromiseAction; + const promiseAction = store.dispatch(autoAdding113) as Action as Action & PromiseAction; const asyncIteratorStarted = assertations.reducedActions! .find((action: Action) => action.type === AsyncIteratorStarted) as AsyncIteratorStarted; should.notStrictEqual(asyncIteratorStarted, undefined); From 07332209f706dee155926f172c512bc9a1dc8da6 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 14:06:52 +0200 Subject: [PATCH 10/17] Extract generateUid into self helper file --- src/Helper/hash.ts | 4 ++++ src/createModelSaga.ts | 5 +---- 2 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 src/Helper/hash.ts diff --git a/src/Helper/hash.ts b/src/Helper/hash.ts new file mode 100644 index 0000000..a840a98 --- /dev/null +++ b/src/Helper/hash.ts @@ -0,0 +1,4 @@ + +export function generateUid() { + return Math.random().toString().substr(2); +} diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index 11a9e59..0787c15 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -1,5 +1,6 @@ import { createStore, Store, Middleware, Dispatch, Action } from 'redux'; +import { generateUid } from './Helper/hash'; import ISaga from './ISaga'; import AsyncIteratorStarted from './AsyncIteratorStarted'; import { @@ -73,7 +74,3 @@ export default function createModelSaga(saga: ISaga) { destroy }; } - -function generateUid() { - return Math.random().toString().substr(2); -} From 24ec380623b0d48e09fedca05ce0c8bcee545442 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 14:11:22 +0200 Subject: [PATCH 11/17] Do wait for allo promises of action are done after all saga updater actions are processed --- src/createModelSaga.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index 0787c15..f243a5d 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -11,12 +11,11 @@ async function update( asyncIterator: AsyncIterableIterator, dispatch: Dispatch, ) { + const promises = []; for await (let value of asyncIterator) { if (typeof value === 'object') { const promiseAction = dispatch(value) as PromiseAction & Action; - if (promiseAction.__promise instanceof Promise) { - await promiseAction.__promise; - } + promises.push(promiseAction.__promise); } else { const error = new Error( 'Updater must yield action. ' + JSON.stringify(value) + ' given.' @@ -28,6 +27,7 @@ async function update( } } } + await Promise.all(promises); } export default function createModelSaga(saga: ISaga) { From 0b7a47fcfdaf1382b436d1bc2eca9a5c6eb9f21c Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 14:18:02 +0200 Subject: [PATCH 12/17] Extract extending action with internal property to self helper file --- src/Helper/property.ts | 9 +++++++++ src/createModelSaga.ts | 9 +++------ 2 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 src/Helper/property.ts diff --git a/src/Helper/property.ts b/src/Helper/property.ts new file mode 100644 index 0000000..ce03f21 --- /dev/null +++ b/src/Helper/property.ts @@ -0,0 +1,9 @@ + +export function extendWithInternalProperty(object: object, property: string, value: any) { + Object.defineProperty(object, property, { + enumerable: false, + configurable: false, + writable: false, + value, + }); +} diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index f243a5d..c063b24 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -1,10 +1,12 @@ import { createStore, Store, Middleware, Dispatch, Action } from 'redux'; import { generateUid } from './Helper/hash'; +import { extendWithInternalProperty } from './Helper/property'; import ISaga from './ISaga'; import AsyncIteratorStarted from './AsyncIteratorStarted'; import { PromiseAction, + promiseProperty, } from './internalActions'; async function update( @@ -52,12 +54,7 @@ export default function createModelSaga(saga: ISaga) { sourceAction: action, } as AsyncIteratorStarted); } - Object.defineProperty(result, '__promise', { - enumerable: false, - configurable: false, - writable: false, - value: promise - }); + extendWithInternalProperty(result, promiseProperty, promise); return result; }; const destroy = () => { From a994e9f0ecf8665cb13690900cee60b45a774094 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 14:27:47 +0200 Subject: [PATCH 13/17] Extend processed actions with source action & async iterator --- src/createModelSaga.ts | 13 +++++++++++-- src/internalActions.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index c063b24..5713f69 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -5,6 +5,10 @@ import { extendWithInternalProperty } from './Helper/property'; import ISaga from './ISaga'; import AsyncIteratorStarted from './AsyncIteratorStarted'; import { + SourceAction, + sourceActionProperty, + AsyncIteratorAction, + asyncIteratorProperty, PromiseAction, promiseProperty, } from './internalActions'; @@ -42,12 +46,16 @@ export default function createModelSaga(saga: ISaga) { const asyncIterator = saga.updater(model, action); const asyncIteratorUid = generateUid(); asyncIterators[asyncIteratorUid] = asyncIterator; - const promise = update(asyncIterator, store.dispatch) + const baseDispatch = (nextAction: A) => { + extendWithInternalProperty(nextAction, sourceActionProperty, action); + return store.dispatch(nextAction); + }; + const promise = update(asyncIterator, baseDispatch) .then(() => { delete asyncIterators[asyncIteratorUid]; }); if (action.type !== AsyncIteratorStarted) { - store.dispatch({ + baseDispatch({ type: AsyncIteratorStarted, asyncIterator, promise, @@ -55,6 +63,7 @@ export default function createModelSaga(saga: ISaga) { } as AsyncIteratorStarted); } extendWithInternalProperty(result, promiseProperty, promise); + extendWithInternalProperty(result, asyncIteratorProperty, asyncIterator); return result; }; const destroy = () => { diff --git a/src/internalActions.ts b/src/internalActions.ts index 458980a..1538f7b 100644 --- a/src/internalActions.ts +++ b/src/internalActions.ts @@ -1,4 +1,16 @@ +import { Action } from 'redux'; + +export const sourceActionProperty = '@@aperol/sourceAction'; +export interface SourceAction { + '@@aperol/sourceAction': TAction; +} + +export const asyncIteratorProperty = '@@aperol/asyncIterator'; +export interface AsyncIteratorAction { + '@@aperol/asyncIterator': AsyncIterableIterator; +} + export const promiseProperty = '__promise'; export interface PromiseAction { '__promise': Promise; From 4382ae911e91fd8882c99ef34dca94cba5047c7f Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 14:29:20 +0200 Subject: [PATCH 14/17] Add simple dispatch exported in created modelSaga * to allow use modelSaga without redux as middleware --- src/createModelSaga.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index 5713f69..afb2fa3 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -77,6 +77,12 @@ export default function createModelSaga(saga: ISaga) { }; return { middleware, - destroy + dispatch(action: TAction): TAction & PromiseAction & SourceAction & AsyncIteratorAction { + return middleware({ + dispatch: ((a: Action) => this.dispatch(a) as any), + getState: () => null, + })(((a: Action) => a) as Dispatch)(action) as TAction & PromiseAction & SourceAction & AsyncIteratorAction; + }, + destroy, }; } From 4a28ab8ef25b23c7ae9d18efb7c456eab6de5ca3 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 14:32:25 +0200 Subject: [PATCH 15/17] Add getModel exported function to allow show currect sagas model --- src/createModelSaga.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index afb2fa3..100cc48 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -83,6 +83,7 @@ export default function createModelSaga(saga: ISaga) { getState: () => null, })(((a: Action) => a) as Dispatch)(action) as TAction & PromiseAction & SourceAction & AsyncIteratorAction; }, + getModel: () => sagaStore.getState(), destroy, }; } From b864ae40fc6e919dfaf1b97d2ff4d5cbe6585263 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Sun, 6 Aug 2017 20:25:23 +0200 Subject: [PATCH 16/17] Implement running & making continual side-effects * which are running for longer time until are done * it will not be ran on server-side to prevent special unnecesary calls & to know when request is fullfiled to be rendered & served --- .../ContinualAsyncIteratorStarted.ts | 11 ++ .../RunAsyncIteratorGeneratorContinual.ts | 11 ++ src/Continual/continualActionCreators.ts | 12 ++ src/Continual/continualSaga.ts | 36 ++++++ src/createModelSaga.ts | 9 +- src/index.ts | 2 + tests/unit/Continual/runItContinual.spec.ts | 111 ++++++++++++++++++ tests/unit/fakeTimer.ts | 11 ++ tests/unit/timer.ts | 11 ++ 9 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 src/Continual/ContinualAsyncIteratorStarted.ts create mode 100644 src/Continual/RunAsyncIteratorGeneratorContinual.ts create mode 100644 src/Continual/continualActionCreators.ts create mode 100644 src/Continual/continualSaga.ts create mode 100644 tests/unit/Continual/runItContinual.spec.ts create mode 100644 tests/unit/fakeTimer.ts create mode 100644 tests/unit/timer.ts diff --git a/src/Continual/ContinualAsyncIteratorStarted.ts b/src/Continual/ContinualAsyncIteratorStarted.ts new file mode 100644 index 0000000..e7d50ce --- /dev/null +++ b/src/Continual/ContinualAsyncIteratorStarted.ts @@ -0,0 +1,11 @@ + +import { Action } from 'redux'; + +export const ContinualAsyncIteratorStarted = '@@aperol/Continual/ContinualAsyncIteratorStarted'; +export interface ContinualAsyncIteratorStarted { + type: typeof ContinualAsyncIteratorStarted; + asyncIterator: AsyncIterableIterator; + promise: Promise; + sourceAction: TSourceAction; +} +export default ContinualAsyncIteratorStarted; diff --git a/src/Continual/RunAsyncIteratorGeneratorContinual.ts b/src/Continual/RunAsyncIteratorGeneratorContinual.ts new file mode 100644 index 0000000..fe8cd37 --- /dev/null +++ b/src/Continual/RunAsyncIteratorGeneratorContinual.ts @@ -0,0 +1,11 @@ + +import { Action } from 'redux'; +import { SourceAction } from '../internalActions'; + +export const RunAsyncIteratorGeneratorContinual = '@@aperol/Continual/RunAsyncIteratorGeneratorContinual'; +export interface RunAsyncIteratorGeneratorContinual extends SourceAction { + type: typeof RunAsyncIteratorGeneratorContinual; + uid: string; + asyncIteratorGenerator: () => AsyncIterableIterator; +} +export default RunAsyncIteratorGeneratorContinual; diff --git a/src/Continual/continualActionCreators.ts b/src/Continual/continualActionCreators.ts new file mode 100644 index 0000000..367c843 --- /dev/null +++ b/src/Continual/continualActionCreators.ts @@ -0,0 +1,12 @@ + +import { Action } from 'redux'; +import { generateUid } from '../Helper/hash'; +import RunAsyncIteratorGeneratorContinual from './RunAsyncIteratorGeneratorContinual'; + +export function runItContinual(asyncIteratorGenerator: () => AsyncIterableIterator) { + return { + type: RunAsyncIteratorGeneratorContinual, + uid: generateUid(), + asyncIteratorGenerator, + } as RunAsyncIteratorGeneratorContinual; +} diff --git a/src/Continual/continualSaga.ts b/src/Continual/continualSaga.ts new file mode 100644 index 0000000..6e43d8b --- /dev/null +++ b/src/Continual/continualSaga.ts @@ -0,0 +1,36 @@ + +import { Action } from 'redux'; +import { + PromiseAction, + sourceActionProperty, +} from '../internalActions'; +import ContinualAsyncIteratorStarted from './ContinualAsyncIteratorStarted'; +import RunAsyncIteratorGeneratorContinual from './RunAsyncIteratorGeneratorContinual'; + +export interface Model {} + +const initialModel = {}; + +export function reducer(model: Model = initialModel, _action: Action) { + return model; +} + +export async function *updater( + _model: Model, + action: RunAsyncIteratorGeneratorContinual & PromiseAction +) { + switch (action.type) { + case RunAsyncIteratorGeneratorContinual: + const sourceAction = action[sourceActionProperty]; + const asyncIterator = action.asyncIteratorGenerator(); + yield { + type: ContinualAsyncIteratorStarted, + asyncIterator, + sourceAction, + promise: action.__promise, + } as ContinualAsyncIteratorStarted; + yield* asyncIterator; + break; + default: + } +} diff --git a/src/createModelSaga.ts b/src/createModelSaga.ts index 100cc48..4efb221 100644 --- a/src/createModelSaga.ts +++ b/src/createModelSaga.ts @@ -12,6 +12,8 @@ import { PromiseAction, promiseProperty, } from './internalActions'; +import combineSagas from './combineSagas'; +import * as continual from './Continual/continualSaga'; async function update( asyncIterator: AsyncIterableIterator, @@ -37,13 +39,14 @@ async function update( } export default function createModelSaga(saga: ISaga) { - const sagaStore = createStore(saga.reducer); + const extendedSaga = combineSagas({ application: saga, continual }); + const sagaStore = createStore(extendedSaga.reducer); const asyncIterators: { [asyncIteratorUid: string]: AsyncIterableIterator } = {}; const middleware: Middleware = (store: Store) => (nextDispatch: Dispatch) => (action: TAction) => { const result = nextDispatch(action); sagaStore.dispatch(action); const model = sagaStore.getState(); - const asyncIterator = saga.updater(model, action); + const asyncIterator = extendedSaga.updater(model, action); const asyncIteratorUid = generateUid(); asyncIterators[asyncIteratorUid] = asyncIterator; const baseDispatch = (nextAction: A) => { @@ -83,7 +86,7 @@ export default function createModelSaga(saga: ISaga) { getState: () => null, })(((a: Action) => a) as Dispatch)(action) as TAction & PromiseAction & SourceAction & AsyncIteratorAction; }, - getModel: () => sagaStore.getState(), + getModel: () => sagaStore.getState().application, destroy, }; } diff --git a/src/index.ts b/src/index.ts index 249970e..abf1879 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export { default as createModelSaga } from './createModelSaga'; export { default as combineSagas } from './combineSagas'; +export { runItContinual } from './Continual/continualActionCreators'; +export { default as RunAsyncIteratorGeneratorContinual } from './Continual/RunAsyncIteratorGeneratorContinual'; export { default as AsyncIteratorStarted } from './AsyncIteratorStarted'; export { PromiseAction } from './internalActions'; export { default as ISaga } from './ISaga'; diff --git a/tests/unit/Continual/runItContinual.spec.ts b/tests/unit/Continual/runItContinual.spec.ts new file mode 100644 index 0000000..b091e08 --- /dev/null +++ b/tests/unit/Continual/runItContinual.spec.ts @@ -0,0 +1,111 @@ + +import * as should from 'should'; +import { Action } from 'redux'; +import { runItContinual } from '../../../src/Continual/continualActionCreators'; +import createModelSaga from '../../../src/createModelSaga'; +import ContinualAsyncIteratorStarted from '../../../src/Continual/ContinualAsyncIteratorStarted'; +import { repeat as fakeRepeat } from '../fakeTimer'; +import { wait } from '../timer'; + +describe('runItContinual', function () { + + let doRepeat: () => void; + const getRepeatTick = (tick: () => void) => doRepeat = tick; + + const WelterBegun = 'WelterBegun'; + interface WelterBegun { + type: typeof WelterBegun; + greetsLimit: number; + } + + const Greeted = 'Greeted'; + interface Greeted { + type: typeof Greeted; + } + + const WelterTired = 'WelterTired'; + interface WelterTired { + type: typeof WelterTired; + } + + interface GreetModel { + numberOfGreets: number; + currentGreetsLimit?: number; + currentGreeting?: AsyncIterableIterator; + } + + const initialModel = { + numberOfGreets: 0, + }; + + const greetSaga = { + reducer( + model: GreetModel = initialModel, + action: Greeted | WelterBegun | WelterTired | ContinualAsyncIteratorStarted + ) { + switch (action.type) { + case WelterBegun: + return { ...model, currentGreetsLimit: action.greetsLimit }; + case Greeted: + return { ...model, numberOfGreets: model.numberOfGreets + 1 }; + case ContinualAsyncIteratorStarted: + return action.sourceAction.type === WelterBegun ? { ...model, currentGreeting: action.asyncIterator } : model; + case WelterTired: + return { ...model, currentGreeting: undefined, currentGreetsLimit: undefined }; + default: + return model; + } + }, + async *updater(model: GreetModel, action: Greeted | WelterBegun) { + switch (action.type) { + case WelterBegun: + yield runItContinual(async function* () { + for await (let _ of fakeRepeat(getRepeatTick)) { + yield { type: Greeted }; + } + }); + break; + case Greeted: + if (model.currentGreeting && model.currentGreetsLimit && model.numberOfGreets >= model.currentGreetsLimit) { + await model.currentGreeting.return!(); + yield { type: WelterTired }; + } + break; + default: + } + }, + }; + + it('should greet 3 times by welter & then stop because welter is tired', async function () { + const modelSaga = createModelSaga(greetSaga); + should.strictEqual(modelSaga.getModel().currentGreeting, undefined); + modelSaga.dispatch({ + type: WelterBegun, + greetsLimit: 3, + }); + should.strictEqual(modelSaga.getModel().currentGreetsLimit, 3); + await wait(0); + should.notStrictEqual(modelSaga.getModel().currentGreeting, undefined); + should.strictEqual(modelSaga.getModel().numberOfGreets, 0); + doRepeat(); + await wait(0); + should.notStrictEqual(modelSaga.getModel().currentGreeting, undefined); + should.strictEqual(modelSaga.getModel().numberOfGreets, 1); + doRepeat(); + await wait(0); + should.notStrictEqual(modelSaga.getModel().currentGreeting, undefined); + should.strictEqual(modelSaga.getModel().numberOfGreets, 2); + doRepeat(); + await wait(0); + should.strictEqual(modelSaga.getModel().currentGreeting, undefined); + should.strictEqual(modelSaga.getModel().numberOfGreets, 3); + doRepeat(); + await wait(0); + should.strictEqual(modelSaga.getModel().currentGreeting, undefined); + should.strictEqual(modelSaga.getModel().numberOfGreets, 3); + doRepeat(); + await wait(0); + should.strictEqual(modelSaga.getModel().currentGreeting, undefined); + should.strictEqual(modelSaga.getModel().numberOfGreets, 3); + }); +}); diff --git a/tests/unit/fakeTimer.ts b/tests/unit/fakeTimer.ts new file mode 100644 index 0000000..a6d4528 --- /dev/null +++ b/tests/unit/fakeTimer.ts @@ -0,0 +1,11 @@ + +export const wait = (getTick: (tick: () => void) => void) => new Promise( + (resolve: () => void) => getTick(() => resolve()) +); + +export async function* repeat(getTick: (tick: () => void) => void): AsyncIterableIterator { + while (true) { + await wait(getTick); + yield; + } +} diff --git a/tests/unit/timer.ts b/tests/unit/timer.ts new file mode 100644 index 0000000..9837ca2 --- /dev/null +++ b/tests/unit/timer.ts @@ -0,0 +1,11 @@ + +export const wait = (timeout: number) => new Promise( + (resolve: () => void) => setTimeout(resolve, timeout) +); + +export async function* repeat(interval: number): AsyncIterableIterator { + while (true) { + await wait(interval); + yield; + } +} From 166b400eb93d8ac1eab638d2a99bf0b4a041a3e6 Mon Sep 17 00:00:00 2001 From: Michael Zabka Date: Mon, 20 Nov 2017 02:41:59 +0100 Subject: [PATCH 17/17] Make longer wait time in tests for promises are done --- tests/unit/createModelSaga.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/createModelSaga.spec.ts b/tests/unit/createModelSaga.spec.ts index cebb3e9..7ccfa7a 100644 --- a/tests/unit/createModelSaga.spec.ts +++ b/tests/unit/createModelSaga.spec.ts @@ -75,13 +75,13 @@ describe('Application.craeteModelSaga', function () { autoAdding113, ]); autoAdding113.__doAdd!(); - await new Promise((resolve: () => void) => setTimeout(resolve, 2)); + await new Promise((resolve: () => void) => setTimeout(resolve, 20)); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, added, ]); autoAdding113.__doAdd!(); - await new Promise((resolve: () => void) => setTimeout(resolve, 2)); + await new Promise((resolve: () => void) => setTimeout(resolve, 20)); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, added, @@ -105,7 +105,7 @@ describe('Application.craeteModelSaga', function () { autoAdding113, ]); autoAdding113.__doAdd!(); - await new Promise((resolve: () => void) => setTimeout(resolve, 2)); + await new Promise((resolve: () => void) => setTimeout(resolve, 20)); should.deepEqual(removeInternalActions(assertations.reducedActions), [ autoAdding113, added, @@ -115,7 +115,7 @@ describe('Application.craeteModelSaga', function () { // In native asyncIterator it is not waiting for promise & return (throw) immediatelly // According to https://github.com/bergus/promise-cancellation/blob/master/API.md it is maybe not possible now autoAdding113.__doAdd!(); - await new Promise((resolve: () => void) => setTimeout(resolve, 2)); + await new Promise((resolve: () => void) => setTimeout(resolve, 20)); const autoAddingAsyncIteratorStartedAction = assertations.dispatchedActions! .find((action: AsyncIteratorStarted) => action.type === AsyncIteratorStarted && action.sourceAction === autoAdding113); should(autoAddingAsyncIteratorStartedAction).not.empty();