From 61d5a5f82bc0bf00e3b7f88e06e0741dd7304548 Mon Sep 17 00:00:00 2001 From: Dean Radcliffe Date: Fri, 26 Mar 2021 11:40:00 -0500 Subject: [PATCH 1/3] 1.2.6 After can defer an Observable --- CHANGELOG.md | 17 ++++ README.md | 209 +++++++++++++++++++++++++++------------------ package.json | 2 +- src/utils.ts | 8 +- test/utils.test.ts | 12 ++- 5 files changed, 160 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 053e2ba..4e88954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ +- [1.2.6 After can defer an Observable.](#126-after-can-defer-an-observable) +- [1.2.5 Allow listener-returned bare values or generators](#125-allow-listener-returned-bare-values-or-generators) - [1.2.4 Can declaratively fire a 'start' event upon Observable subscription](#124-can-declaratively-fire-a-start-event-upon-observable-subscription) - [1.2.3 Important bug fix](#123-important-bug-fix) - [1.2 Smaller Bundle, More Robust](#12-smaller-bundle-more-robust) @@ -19,6 +21,21 @@ +### 1.2.6 After can defer an Observable. + +It'd be handy to have `after(100, ob$)` +defer the Observable-now it does. Important to note: this is not +the same as `obs.pipe(delay(N))`, which delays the notifications, but still eagerly subscribes. `after` defers +the subscription itself, so if canceled early enough, the side effect does not +have to occur. Perfect for debouncing, with `mode: replace`. + +usually (which is why you can call `toPromise` on it, or `await` it) + +### 1.2.5 Allow listener-returned bare values or generators + +Listeners ought to return Observables, but when they return an iterable, which could be a generator, +how should the values be provided? They generally become `next` notifications individually, to preserve cases where, like Websockets, many notifications come in incrementally. However, a String is iterable, and it seemed a bug to `next` each letter of the string. + #### 1.2.4 Can declaratively fire a 'start' event upon Observable subscription For feature-parity with conventions like for Redux Query, and those diff --git a/README.md b/README.md index a96b8f3..04c1acf 100644 --- a/README.md +++ b/README.md @@ -36,103 +36,172 @@ Because of it's pub-sub/event-bus design, your app remains inherently scalable b The framework-independent primitives of `polyrhythm` can be used anywhere. It adds only 3Kb to your bundle, so it's worth a try. It is test-covered, provides types, is production-tested and performance-tested. -# How Does It Help Me Build Apps? +--- -A polyrhythm app, sync or async, can be built out of 6 or fewer primitives: +# Declare Your Timing, Don't Code It -- `trigger` - Puts an event on the event bus, and must be called at least once in your app. Generally all a UI Event Handler needs to do is call `trigger` with an event type and a payload.
Example — `addEventListener('click', ()=>{ trigger('timer/start') })` +RxJS was written in 2010 to address the growing need for async management code in the world of AJAX. Yet in 2021, it can still be a large impact to the codebase to add `async` to a function declaration, or turn a function into a generator with `function*() {}`. That impact can 'hard-code' in unadaptable behaviors or latency. And relying on framework features (like the timing difference between `useEffect` and `useLayoutEffect`) can make code vulnerable to framework changes, and make it harder to test. -- `filter` - Adds a function to be called on every matching `trigger`. The filter function will be called synchronously in the call-stack of `trigger`, can modify its events, and can prevent events from being further handled by throwing an Error.
For metadata — `filter('timer/start', event => { event.payload.startedAt = Date.now()) })`
Validation — `filter('form/submit', ({ payload }) => { isValid(payload) || throw new Error() })` +`polyrhythm` gives you 5 concurrency modes you can plug in trivially as configuration parameters, to get the full power of RxJS elegantly. -- `listen` - Adds a function to be called on every matching `trigger`, once all filters have passed. Allows you to return an Observable of its side-effects, and/or future event triggerings, and configure its overlap behavior / concurrency declaratively.
AJAX: `listen('profile/fetch', ({ payload }) => get('/user/' + payload.id)).tap(user => trigger('profile/complete', user.profile))` +The listener option `mode` allows you to control the concurrency behavior of a listener declaratively, and is important for making polyrhythm so adaptable to desired timing outcomes. For an autocomplete or session timeout, the `replace` mode is appropriate. For other use cases, `serial`, `parallel` or `ignore` may be appropriate. -- `query` - Provides an Observable of matching events from the event bus. Useful when you need to create a derived Observable for further processing, or for controlling/terminating another Observable. Example: `interval(1000).takeUntil(query('user/activity'))` +If async effects like AJAX were represented as sounds, this diagram shows how they might overlap/queue/cancel each other. -## Observable creators + -- `after` - Defers a function call into an Observable of that function call, after a delay. This is the simplest way to get a cancelable side-effect, and can be used in places that expect either a `Promise` or an `Observable`.
Promise — `await after(10000, () => modal('Your session has expired'))`
Observable — `interval(1000).takeUntil(after(10000))` - ` -- `concat` - Combines Observables by sequentially starting them as each previous one finishes. This only works on Observables which are deferred, not Promises which are begun at their time of creation.
Sequence — `login().then(() => concat(after(9000, 'Your session is about to expire'), after(1000, 'Your session has expired')).subscribe(modal))` +Being able to plug in a strategy ensures that the exact syntax of your code, and your timing information, are decoupled - the one is not expressed in terms of the other. This lets you write fewer lines, be more direct and declarative, and generally cut down on race conditions. + +Not only do these 5 modes handle not only what you'd want to do with RxJS, but they handle anything your users would expect code to do when async process overlap! You have the ease to change behavior to satisfy your pickiest users, without rewriting code - you only have to update your tests to match! -You can use Observables from any source in `polyrhythm`, not just those created with `concat` and `after`. For maximum flexibility, use the `Observable` constructor to wrap any async operation - and use them anywhere you need more control over the Observables behavior. Be sure to return a cleanup function from the Observable constructor +![](https://s3.amazonaws.com/www.deanius.com/async-mode-table.png) + +Now let's dig into some examples. + +--- + +It's all RxJS underneath of course, but simpler, as shown in these examples. + +## Example 1: Auto-Complete Input (replace mode) + +Based on the original example at [LearnRxjs.io](https://www.learnrxjs.io/learn-rxjs/recipes/type-ahead)... + +**Set up an event handler to trigger `search/start` events from an onChange:** ```js -listen('user/activity', () => { - return concat( - new Observable(notify => { // equivalent to after(9000, "Your session is about to expire") - const id = setTimeout(() => { - notify.next("Your session is about to expire"); - notify.complete(); // tells `concat` we're done- Observables may call next() many times - }, 9000); - return () => clearTimeout(id); // a cancelation function allowing this timeout to be 'replaced' with a new one - }), - after(1000, () => "Your session has expired")); -}, { mode: 'replace' }); + trigger('search/start', e.target.value)}/> +``` + +**Listen for the `search/results` event and update component or global state:** + +```js +filter('search/results', ({ payload: results }) => { + setResults(results) }); ``` -
- -More Explanation - -According to Pub-Sub, there are publishers and subscribers. In `polyrhythm` there are event **Originators** (publishers) which call `trigger`, and **Handlers** which `filter`, or `listen` in one of several concurrency modes. +**Respond to `search/start` events with an Observable, or Promise of the ajax request.** -## Event Originators +Assign the output to the `search/results` event, and specify your `mode`, and you're done and race-condition-free! -**Events** are `trigger`-ed by an **Originator**. +```js +on('search/start', ({ payload }) => { + return fetch(URL + payload).then(res => res.json()) +}, { + mode: 'replace', + trigger: { next: 'search/results' } +}); +``` -## The Channel +`mode:replace` does what `switchMap` does, but with readability foremost, and without requiring you to model your app as a chained Observable, or manage Subscription objects or call `.subscribe()` or `.unsubscribe()` explicitly. -An instance of an event-bus is called a **Channel**. There's a default channel, to which top-level exports `filter`, `trigger`, and `listen` are bound. +[Debounced Search CodeSandbox](https://codesandbox.io/s/debounced-search-polyrhythm-react-w1t8o?file=/src/App.js) -## Handlers +## Example 2: Ajax Cat Fetcher (multi-mode) +Based on an [XState Example](https://dev.to/davidkpiano/no-disabling-a-button-is-not-app-logic-598i) showing the value of separating out effects from components, and how to be React Concurrent Mode (Suspense-Mode) safe, in XState or Polyrhythm. -**Handlers** are either **Filters** or **Listeners**. Both specify: +Try it out - play with it! Is the correct behavior to use `serial` mode to allow you to queue up cat fetches, or `ignore` to disable new cats while one is loading, as XState does? You choose! I find having these options easily pluggble enables the correct UX to be discovered through play, and tweaked with minimal effort. -- A function to run -- An event criteria for when to run the function +[Cat Fetcher AJAX CodeSandbox](https://codesandbox.io/s/cat-fetcher-with-polyrhythm-uzjln?file=/src/handlers.js) -The difference is how they execute, and their relative decoupling of isolation. +## Example 3: Redux Toolkit Counter (multi-mode) -**Filters** are run synchronously with `trigger()` prior to events arriving on the event bus. +All 5 modes can be tried in the polyrhythm version of the + [Redux Counter Example Sandbox](https://codesandbox.io/s/poly-redux-counter-solved-m5cm0) + +--- -_IMPORTANT: **Filters** can modify events. And their exceptions propogate up to the **Originator**. So one of their uses is to prevent **Listeners** from responding._ +# Can I use Promises instead of Observables? +Recall the auto-complete example, in which you could create a new `search/results` event from either a Promise or Observable: -**Listeners** are run when events make it through all filters. +```js +on('search/start', ({ payload }) => { + // return Observable + return ajax.get(URL + payload).pipe( + tap({ results } => results) + ); + // OR Promise + return fetch(URL + payload).then(res => res.json()) +}, { + mode: 'replace', + trigger: { next: 'search/results' } +}); +``` +With either the Promise, or Observable, the `mode: replace` guarantees your autocomplete never has the race-condition where an old result populates after new letters invalidate it. But with an Observable: -**Listeners** are often **Originators**, when they `trigger` new events. +- The AJAX can be canceled, freeing up bandwidth as well +- The AJAX can be set to be canceled implicitly upon component unmount, channel reset, or by another event declaratively with `takeUntil`. And no Abort Controllers or `await` ever required! -**Listeners** are how to do async. They return Promises, or **Tasks**— RxJS Observables. +You have to return an Observable to get cancelation, and you only get all the overlap strategies and lean performance when you can cancel. So best practice is to use them - but they are not required. -A **Task** is a cancelable, unstarted object which the listener may run, or cancel upon a new event. +--- +# UI Layer Bindings -**Listeners** can be provided a concurrency `mode` to control what happens when events come in fast, so that the execution of their **Task**s overlap. Modes include common strategies like enqueueing or canceling the previous. +`trigger`, `filter` `listen` (aka `on`), and `query` are methods bound to an instance of a `Channel`. For convenience, and in many examples, these bound methods may be imported and used directly -_IMPORTANT: The app is protected from each **Listener** as though by a fuse. `polyrhythm` intercepts uncaught exceptions and terminates only the offending listener._ +```js +import { trigger, on } from 'polyrhythm'; +on(...) +trigger(...) +``` +These top-level imports are enough to get started, and one channel is usually enough per JS process. However you may want more than one channel, or have control over its creation: -
+```js +import { Channel } from 'polyrhythm'; +const channel = new Channel(); +channel.trigger(...) +``` ---- +(In a React environment, a similar choice exists- a top-level `useListener` hook, or a listener bound to a channel via `useChannel`. React equivalents are discussed further in the [polyrhythm-react](https://github.com/deanius/polyrhythm-react) repo) -# Declare Your Timing, Don't Code It +To tie cancelation into your UI layer's component lifecycle (or server-side request fulfillment if in Node), call `.unsubscribe()` on the return value from `channel.listen` or `channel.filter` for any handlers the component set up: -Most of the time, app code around timing is extremely hard to change. It can be a large impact to the codebase to add `async` to a function declaration, or turn a function into a generator with `function*() {}`. That impact can 'hard-code' in latency or unadaptable behaviors. And relying on framework features like the timing difference between `useEffect` and `useLayoutEffect` can make code vulnerable to framework changes, and make it harder to test. +```js +// at mount +const sub = channel.on(...).. +// at unmount +sub.unsubscribe() +``` -`polyrhythm` gives you 5 concurrency modes you can plug in trivially as configuration parameters. See it's effect on the "Increment Async" behavior in the [Redux Counter Example](https://codesandbox.io/s/poly-redux-counter-solved-m5cm0). +Lastly in a hot-module-reloading environment, `channel.reset()` is handy to remove all listeners, canceling their effects. Include that call early in the loading process to avoid double-registration of listeners in an HMR environment. -The listener option `mode` allows you to control the concurrency behavior of a listener declaratively, and is important for making polyrhythm so adaptible to desired timing outcomes. For an autocomplete or session timeout, the `replace` mode is appropriate. For other use cases, `serial`, `parallel` or `ignore` may be appropriate.
`listen('user/activity', () => concat(after(9000, 'Your session is about to expire'), after(1000, 'Your session has expired')), { mode: 'replace' })` +# API +A polyrhythm app, sync or async, can be built out of 6 or fewer primitives: -If async effects were sounds, this diagram shows how they might overlap/queue/cancel each other. +- `trigger` - Puts an event on the event bus, and should be called at least once in your app. Generally all a UI Event Handler needs to do is call `trigger` with an event type and a payload.
Example — `addEventListener('click', ()=>{ trigger('timer/start') })` - +- `filter` - Adds a function to be called on every matching `trigger`. The filter function will be called synchronously in the call-stack of `trigger`, can modify its events, and can prevent events from being further handled by throwing an Error.
For metadata — `filter('timer/start', event => { event.payload.startedAt = Date.now()) })`
Validation — `filter('form/submit', ({ payload }) => { isValid(payload) || throw new Error() })` + +- `listen` - Adds a function to be called on every matching `trigger`, once all filters have passed. Allows you to return an Observable of its side-effects, and/or future event triggerings, and configure its overlap behavior / concurrency declaratively.
AJAX: `listen('profile/fetch', ({ payload }) => get('/user/' + payload.id)).tap(user => trigger('profile/complete', user.profile))` + +- `query` - Provides an Observable of matching events from the event bus. Useful when you need to create a derived Observable for further processing, or for controlling/terminating another Observable. Example: `interval(1000).takeUntil(query('user/activity'))` -> Watch a [Loom Video on these concurrency modes](https://www.loom.com/share/3736003a75bd497eab062c97af0113fc) +## Observable creators -This ensures that the exact syntax of your code, and your timing information, are decoupled - the one is not expressed in terms of the other. This let's you write fewer lines, more direct and declarative, and generally more managable code. +- `after` - Defers a function call into an Observable of that function call, after a delay. This is the simplest way to get a cancelable side-effect, and can be used in places that expect either a `Promise` or an `Observable`.
Promise — `await after(10000, () => modal('Your session has expired'))`
Observable — `interval(1000).takeUntil(after(10000))` + ` +- `concat` - Combines Observables by sequentially starting them as each previous one finishes. This only works on Observables which are deferred, not Promises which are begun at their time of creation.
Sequence — `login().then(() => concat(after(9000, 'Your session is about to expire'), after(1000, 'Your session has expired')).subscribe(modal))` + +You can use Observables from any source in `polyrhythm`, not just those created with `concat` and `after`. For maximum flexibility, use the `Observable` constructor to wrap any async operation - and use them anywhere you need more control over the Observables behavior. Be sure to return a cleanup function from the Observable constructor, as in this session-timeout example. + +```js +listen('user/activity', () => { + return concat( + new Observable(notify => { // equivalent to after(9000, "Your session is about to expire") + const id = setTimeout(() => { + notify.next("Your session is about to expire"); + notify.complete(); // tells `concat` we're done- Observables may call next() many times + }, 9000); + return () => clearTimeout(id); // a cancelation function allowing this timeout to be 'replaced' with a new one + }), + after(1000, () => "Your session has expired")); +}, { mode: 'replace' }); +}); +``` --- -## Examples - What Can You Build With It? +## List Examples - What Can You Build With It? - The [Redux Counter Example](https://codesandbox.io/s/poly-redux-counter-solved-m5cm0) - The [Redux Todos Example](https://codesandbox.io/s/polyrhythm-redux-todos-ltigo) @@ -141,34 +210,6 @@ This ensures that the exact syntax of your code, and your timing information, ar - The [Chat UI Example](https://codesandbox.io/s/poly-chat-imw2z) with TypingIndicator - See [All CodeSandbox Demos](https://codesandbox.io/search?refinementList%5Bnpm_dependencies.dependency%5D%5B0%5D=`polyrhythm`&page=1&configure%5BhitsPerPage%5D=12) - # FAQ @@ -191,7 +232,7 @@ Nearly as fast as RxJS. The [Travis CI build output](https://travis-ci.org/githu --- -# Example: Ping Pong 🏓 +# Tutorial: Ping Pong 🏓
diff --git a/package.json b/package.json index 91e9452..c523856 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "polyrhythm", - "version": "1.2.5", + "version": "1.2.6", "license": "MIT", "author": "Dean Radcliffe", "repository": "https://github.com/deanius/polyrhythm", diff --git a/src/utils.ts b/src/utils.ts index 7afc09b..b9310ae 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -50,13 +50,17 @@ export const randomId = (length: number = 7) => { */ export function after( ms: number, - objOrFn?: T | Thunk + objOrFn?: T | Thunk | Observable ): AwaitableObservable { const delay = ms <= 0 ? of(0) : ms === Infinity ? NEVER : timer(ms); const resultObs: Observable = delay.pipe( // @ts-ignore - map(() => (typeof objOrFn === 'function' ? objOrFn() : objOrFn)) + objOrFn?.subscribe + ? // @ts-ignore + mergeMap(() => objOrFn) + : // @ts-ignore + map(() => (typeof objOrFn === 'function' ? objOrFn() : objOrFn)) ); // after is a 'thenable, thus usable with await. diff --git a/test/utils.test.ts b/test/utils.test.ts index 1b5dca2..e98ad88 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { after, microq, macroq, microflush, macroflush } from '../src/utils'; -import { concat, timer } from 'rxjs'; +import { concat, timer, of } from 'rxjs'; import { fakeSchedulers } from 'rxjs-marbles/mocha'; describe('Utilities', () => { @@ -71,6 +71,16 @@ describe('Utilities', () => { expect(result).to.eql(2.718); }); }); + describe('when an Observable', () => { + it('delays the subscribe to the Observable without producing a value', async () => { + // const subject = after(10, of(2.718)); + const subject = after(10, of(2.718)); + const seen: number[] = []; + subject.subscribe(v => seen.push(v)); + await after(11); + expect(seen).to.eql([2.718]); + }); + }); describe('when not provided', () => { it('undefined becomes the value of the Observable', async () => { const result = await after(1).toPromise(); From 3fbb88e6f1edfd88c6a9342152f643389d282624 Mon Sep 17 00:00:00 2001 From: Dean Radcliffe Date: Tue, 16 Mar 2021 00:39:14 -0500 Subject: [PATCH 2/3] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 04c1acf..b7dae35 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,6 @@ Now let's dig into some examples. --- -It's all RxJS underneath of course, but simpler, as shown in these examples. - ## Example 1: Auto-Complete Input (replace mode) Based on the original example at [LearnRxjs.io](https://www.learnrxjs.io/learn-rxjs/recipes/type-ahead)... From e692b52be360abb736825eb4619762bc2c917178 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 31 Mar 2021 04:42:17 +0000 Subject: [PATCH 3/3] Bump y18n from 4.0.0 to 4.0.1 Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1. - [Release notes](https://github.com/yargs/y18n/releases) - [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md) - [Commits](https://github.com/yargs/y18n/commits) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ebfe21b..f61ecb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9373,9 +9373,9 @@ xtend@^4.0.0, xtend@~4.0.1: integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== y18n@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" - integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + version "4.0.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" + integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== yallist@^3.0.2: version "3.1.1"