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..b7dae35 100644
--- a/README.md
+++ b/README.md
@@ -36,103 +36,170 @@ 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.
-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
+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!
+
+
+
+Now let's dig into some 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.**
+
+Assign the output to the `search/results` event, and specify your `mode`, and you're done and race-condition-free!
-## Event Originators
+```js
+on('search/start', ({ payload }) => {
+ return fetch(URL + payload).then(res => res.json())
+}, {
+ mode: 'replace',
+ trigger: { next: 'search/results' }
+});
+```
-**Events** are `trigger`-ed by an **Originator**.
+`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.
-## The Channel
+[Debounced Search CodeSandbox](https://codesandbox.io/s/debounced-search-polyrhythm-react-w1t8o?file=/src/App.js)
-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.
+## 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
+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.
-**Handlers** are either **Filters** or **Listeners**. Both specify:
+[Cat Fetcher AJAX CodeSandbox](https://codesandbox.io/s/cat-fetcher-with-polyrhythm-uzjln?file=/src/handlers.js)
-- A function to run
-- An event criteria for when to run the function
+## Example 3: Redux Toolkit Counter (multi-mode)
+
+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)
+
+---
+
+# 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:
+
+```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:
-The difference is how they execute, and their relative decoupling of isolation.
+- 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!
-**Filters** are run synchronously with `trigger()` prior to events arriving on the event bus.
+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.
-_IMPORTANT: **Filters** can modify events. And their exceptions propogate up to the **Originator**. So one of their uses is to prevent **Listeners** from responding._
+---
+# UI Layer Bindings
-**Listeners** are run when events make it through all filters.
+`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
-**Listeners** are often **Originators**, when they `trigger` new events.
+```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:
-**Listeners** are how to do async. They return Promises, or **Tasks**— RxJS Observables.
+```js
+import { Channel } from 'polyrhythm';
+const channel = new Channel();
+channel.trigger(...)
+```
-A **Task** is a cancelable, unstarted object which the listener may run, or cancel upon a new event.
+(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)
-**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.
+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:
-_IMPORTANT: The app is protected from each **Listener** as though by a fuse. `polyrhythm` intercepts uncaught exceptions and terminates only the offending listener._
+```js
+// at mount
+const sub = channel.on(...)..
+// at unmount
+sub.unsubscribe()
+```
-
+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.
----
+# API
+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 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') })`
-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.
+- `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. See it's effect on the "Increment Async" behavior in the [Redux Counter Example](https://codesandbox.io/s/poly-redux-counter-solved-m5cm0).
+- `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 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' })`
+- `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 were 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))`
-> Watch a [Loom Video on these concurrency modes](https://www.loom.com/share/3736003a75bd497eab062c97af0113fc)
+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.
-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.
+```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 +208,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 +230,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();
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"