nanoeffects 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -24
- package/dist/{action-creator-D_c223C7.d.ts → action-creator-jeEqIKgK.d.ts} +73 -32
- package/dist/actions.d.ts +18 -18
- package/dist/actions.js +1 -1
- package/dist/bus-0uYn_kRj.js +1 -0
- package/dist/context-DtUnzC-1.js +1 -0
- package/dist/context-types-Bme1mzkn.d.ts +31 -0
- package/dist/context.d.ts +8 -3
- package/dist/context.js +1 -1
- package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
- package/dist/dev.d.ts +2 -2
- package/dist/dev.js +1 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/event-DVcW0mME.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/match.d.ts +3 -3
- package/dist/match.js +1 -1
- package/dist/{module-WX0Gngnt.d.ts → module-BWMW83qk.d.ts} +38 -38
- package/dist/notrace/{action-creator-Bux7Zzsl.d.ts → action-creator-xpQ_I2h8.d.ts} +73 -32
- package/dist/notrace/actions.d.ts +52 -52
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-37nlVljc.js +1 -0
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-CATXOxtj.d.ts → plugin-6LhNx0th.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +43 -1
- package/dist/observable.js +1 -1
- package/dist/onAbort-BgMfspK4.js +1 -0
- package/dist/oop.d.ts +3 -3
- package/dist/oop.js +1 -1
- package/dist/{plugin-vvkmIIz2.d.ts → plugin-BaErevLy.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +9 -9
- package/dist/signal-field-key-CWT-3OXC.js +1 -0
- package/dist/signal.d.ts +34 -6
- package/dist/signal.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +20 -5
- package/dist/trace.js +1 -1
- package/dist/{types-Bf-LX_tE.d.ts → types-D5xsR37j.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-CPjx0sLm.d.ts → with-policy-BZVRBUOm.d.ts} +1 -1
- package/dist/{with-policy-D-SQRkPh.js → with-policy-C6BIiPIi.js} +1 -1
- package/docs/README.md +14 -1
- package/docs/actions.md +28 -2
- package/docs/chat-example.md +16 -4
- package/docs/chat-widget.md +135 -0
- package/docs/context.md +33 -1
- package/docs/effects.md +20 -9
- package/docs/guide.md +21 -3
- package/docs/migrating-from-effector.md +11 -0
- package/docs/observable.md +43 -0
- package/docs/oop.md +18 -1
- package/docs/package.md +22 -13
- package/docs/plugins.md +69 -11
- package/docs/query-libraries.md +273 -5
- package/docs/react.md +12 -2
- package/docs/scope.md +14 -2
- package/docs/semantics.md +10 -0
- package/docs/server.md +3 -3
- package/docs/stability.md +2 -2
- package/docs/starter.md +3 -3
- package/docs/tracing.md +77 -9
- package/docs/typescript.md +1 -1
- package/llms-full.txt +816 -72
- package/llms.txt +8 -7
- package/package.json +6 -6
- package/skills/nanoeffects/SKILL.md +2 -2
- package/dist/bus-DQDHUN5e.js +0 -1
- package/dist/context-UcmrAIhP.js +0 -1
- package/dist/context-types-nGtzhjxD.d.ts +0 -14
- package/dist/event-0RIBGYqS.js +0 -1
- package/dist/notrace/bus-CGi2lrd5.js +0 -1
- package/dist/notrace/promise-CSLpzwSR.js +0 -1
package/llms-full.txt
CHANGED
|
@@ -10,7 +10,7 @@ Rules that generated code often gets wrong:
|
|
|
10
10
|
- Effects take `ctx` as the first argument: `retry(ctx, fn, options)`, `timeout(ctx, ms, fn)`, `take(ctx, "event")`.
|
|
11
11
|
- Policies wrap a handler: `bus.on("search", latest(async (query, ctx) => { ... }))`.
|
|
12
12
|
- Each part is imported from its own path: `nanoeffects` (`createBus`), `nanoeffects/policies`, `nanoeffects/effects`, `nanoeffects/actions`, `nanoeffects/redux`, `nanoeffects/observable`, `nanoeffects/react` (`reactPlugin`, adds `bus.useEmitter` and `bus.useEventState`; `usePolicy`; needs React), `nanoeffects/svelte` (`sveltePlugin`, adds `bus.eventStore`; needs Svelte), `nanoeffects/vue` (`vuePlugin`, adds `bus.watchEmitter` and `bus.useEventRef`; needs Vue), `nanoeffects/trace` (`createTracer`, loggers, `tracePlugin`), `nanoeffects/dev`.
|
|
13
|
-
- `bus.emit(
|
|
13
|
+
- `bus.emit(action(payload))` runs the action's handler and returns its result. Plain events return nothing.
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
Source: docs/guide.md
|
|
@@ -28,6 +28,23 @@ Climb one step at a time: small bus → policies → effects → handlers and mo
|
|
|
28
28
|
|
|
29
29
|
Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
|
|
30
30
|
|
|
31
|
+
## On this page
|
|
32
|
+
|
|
33
|
+
- [1. Create a bus](#1-create-a-bus)
|
|
34
|
+
- [Listen to every event](#listen-to-every-event)
|
|
35
|
+
- [Pick events with a function](#pick-events-with-a-function)
|
|
36
|
+
- [2. Use the handler context](#2-use-the-handler-context)
|
|
37
|
+
- [3. Abort the previous run with `latest`](#3-abort-the-previous-run-with-latest)
|
|
38
|
+
- [4. Pick another policy when overlap differs](#4-pick-another-policy-when-overlap-differs)
|
|
39
|
+
- [5. Add retry and timeout](#5-add-retry-and-timeout)
|
|
40
|
+
- [6. Run work in parallel or wait for an event](#6-run-work-in-parallel-or-wait-for-an-event)
|
|
41
|
+
- [7. Name events three ways](#7-name-events-three-ways)
|
|
42
|
+
- [8. Bind a handler to an action later](#8-bind-a-handler-to-an-action-later)
|
|
43
|
+
- [9. Mount a domain as a module](#9-mount-a-domain-as-a-module)
|
|
44
|
+
- [10. Add types when you want checking (optional)](#10-add-types-when-you-want-checking-optional)
|
|
45
|
+
- [11. Isolate errors and warn on typos](#11-isolate-errors-and-warn-on-typos)
|
|
46
|
+
- [Where next](#where-next)
|
|
47
|
+
|
|
31
48
|
## 1. Create a bus
|
|
32
49
|
|
|
33
50
|
`on` subscribes. `emit` calls the subscribers. `on` returns the unsubscribe function.
|
|
@@ -48,7 +65,7 @@ bus.emit("greet", "Bob"); // nobody listens
|
|
|
48
65
|
|
|
49
66
|
Handlers run at once, in subscription order.
|
|
50
67
|
|
|
51
|
-
`bus.once` is like `on`, but the handler runs at most once (it is removed before it is invoked). `bus.off("greet")` clears every listener of that event; `bus.off()` with no arguments clears **every** listener on the bus (including `"*"`). `ctx.off` is the same `off` as the bus’s — so `ctx.off()` also clears the whole bus.
|
|
68
|
+
`bus.once` is like `on`, but the handler runs at most once (it is removed before it is invoked). `bus.off("greet")` clears every listener of that event; `bus.off()` with no arguments clears **every** listener on the bus (including `"*"`). `ctx.off` is the same `off` as the bus’s — so `ctx.off()` also clears the whole bus. `bus.get("greet")` gives the listeners that event has right now; `bus.get("greet").length === 0` tells that nobody listens any more (`"*"` gives the any-event listeners, which a named event does not include; `bus.get()` with no name gives every listener of every event). It takes the event name as a string: for an action creator use `creator.type`.
|
|
52
69
|
|
|
53
70
|
```js
|
|
54
71
|
import { createBus } from "nanoeffects";
|
|
@@ -289,10 +306,11 @@ import { all, delay, race, take } from "nanoeffects/effects";
|
|
|
289
306
|
const bus = createBus();
|
|
290
307
|
|
|
291
308
|
// all — run together; one failure cancels the rest
|
|
309
|
+
// child — the context of that starter: it has the `signal`, so the request stops with the group
|
|
292
310
|
bus.on("search", async (id, ctx) => {
|
|
293
311
|
const { user, posts } = await all(ctx, {
|
|
294
|
-
user: (
|
|
295
|
-
posts: (
|
|
312
|
+
user: (child) => api.user(id, child),
|
|
313
|
+
posts: (child) => api.posts(id, child),
|
|
296
314
|
});
|
|
297
315
|
ctx.emit("loaded", { user, posts });
|
|
298
316
|
});
|
|
@@ -563,7 +581,7 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
|
|
|
563
581
|
| `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
|
|
564
582
|
| `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
|
|
565
583
|
| `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
|
|
566
|
-
| `domains-ts` |
|
|
584
|
+
| `domains-ts` | Actions with handlers, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
|
|
567
585
|
| `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
|
|
568
586
|
| `svelte-ts` | The `react-ts` app in Svelte 5, with `eventStore` from `nanoeffects/svelte` | You build a Svelte app |
|
|
569
587
|
|
|
@@ -611,13 +629,13 @@ npm create nanoeffects@latest my-app -- --template domains-ts
|
|
|
611
629
|
| ---- | --- |
|
|
612
630
|
| `src/bus.ts` | Creates the bus. It imports no events and no domain. |
|
|
613
631
|
| `src/app.ts` | Mounts the domains with `modulePlugin`. |
|
|
614
|
-
| `src/domains/catalog/` | Searching products: `catalog.ts` (events and a
|
|
632
|
+
| `src/domains/catalog/` | Searching products: `catalog.ts` (events and an action with a handler), `catalog-handlers.ts` (`debounce` and `latest`), `catalog-module.ts`. |
|
|
615
633
|
| `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
|
|
616
634
|
| `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
|
|
617
635
|
|
|
618
636
|
For a bigger example, a chat in which parts react to each other, see [Build a chat with entities, features, modules and a small server](chat-example.md). It has more layers than `domains-ts`: when domains must react to each other, it keeps them from importing each other by splitting them into entities and features.
|
|
619
637
|
|
|
620
|
-
The
|
|
638
|
+
The actions, jobs and modules themselves are explained in [Actions, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
|
|
621
639
|
|
|
622
640
|
## Read the files
|
|
623
641
|
|
|
@@ -696,24 +714,33 @@ Source: docs/package.md
|
|
|
696
714
|
|
|
697
715
|
Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
|
|
698
716
|
|
|
717
|
+
## Start with these three
|
|
718
|
+
|
|
699
719
|
| Import | What is in it |
|
|
700
720
|
| ------ | ------------- |
|
|
701
721
|
| `nanoeffects` | `createBus`, the types |
|
|
702
722
|
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
|
|
703
723
|
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
|
710
|
-
|
|
|
711
|
-
| `nanoeffects/
|
|
712
|
-
| `nanoeffects/
|
|
713
|
-
| `nanoeffects/
|
|
714
|
-
| `nanoeffects/
|
|
715
|
-
| `nanoeffects/
|
|
716
|
-
| `nanoeffects/
|
|
724
|
+
|
|
725
|
+
## Add the rest when you need it
|
|
726
|
+
|
|
727
|
+
Each row is optional. The "when" column says what need brings you to it.
|
|
728
|
+
|
|
729
|
+
| Import | What is in it | When |
|
|
730
|
+
| ------ | ------------- | ---- |
|
|
731
|
+
| `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
|
|
732
|
+
| `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
|
|
733
|
+
| `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value | a store or client must be on every task |
|
|
734
|
+
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` | an outside `AbortSignal` (request, React Query) must stop tasks |
|
|
735
|
+
| `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` | you write classes, not functions |
|
|
736
|
+
| `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
|
|
737
|
+
| `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
|
|
738
|
+
| `nanoeffects/observable` | `createObserve`, `Subject` | you need an Observable (RxJS) |
|
|
739
|
+
| `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
|
|
740
|
+
| `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
|
|
741
|
+
| `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
|
|
742
|
+
| `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
|
|
743
|
+
| `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) | you want to see what ran and why |
|
|
717
744
|
|
|
718
745
|
The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists all of them, what each returns, and the order to use them in.
|
|
719
746
|
|
|
@@ -804,8 +831,8 @@ import { race, retry } from "nanoeffects/effects";
|
|
|
804
831
|
const bus = createBus();
|
|
805
832
|
|
|
806
833
|
bus.on("open", async (id, ctx) => {
|
|
807
|
-
// with ctx: you pass the context, the function
|
|
808
|
-
const user = await retry(ctx, (
|
|
834
|
+
// with ctx: you pass the context, and the function takes the signal from it
|
|
835
|
+
const user = await retry(ctx, () => fetchUser(id, { signal: ctx.signal }), { times: 3 });
|
|
809
836
|
|
|
810
837
|
// as a starter: a group (race, all, allSettled, timeout) calls it with its child
|
|
811
838
|
const result = await race(ctx, {
|
|
@@ -828,12 +855,52 @@ Source: docs/plugins.md
|
|
|
828
855
|
|
|
829
856
|
Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registry of handlers, diagnostics. Read this page when you want to know which plugins exist, what each one gives you, and in what order to use them. Skip it for a small bus: `createBus()` alone is enough.
|
|
830
857
|
|
|
858
|
+
## On this page
|
|
859
|
+
|
|
860
|
+
- [Use a plugin](#use-a-plugin)
|
|
861
|
+
- [Pick a plugin by task](#pick-a-plugin-by-task)
|
|
862
|
+
- [Main plugins](#main-plugins)
|
|
863
|
+
- [Put a store or a client on every handler: `contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin)
|
|
864
|
+
- [Use `race` and `delay` without `ctx`: `effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin)
|
|
865
|
+
- [Bind or replace a handler: `actionsPlugin`](#bind-or-replace-a-handler-actionsplugin)
|
|
866
|
+
- [Mount a domain as one unit: `modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin)
|
|
867
|
+
- [Extra plugins](#extra-plugins)
|
|
868
|
+
- [Give one action its own fields: `scopePlugin`](#give-one-action-its-own-fields-scopeplugin)
|
|
869
|
+
- [Tie an emit or a listener to an outside signal: `signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin)
|
|
870
|
+
- [Name the origin and the flow of a trace: `tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin)
|
|
871
|
+
- [Listen to a group of events: `matchPlugin`](#listen-to-a-group-of-events-matchplugin)
|
|
872
|
+
- [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
|
|
873
|
+
- [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
|
|
874
|
+
- [Find leaks while you develop: `devPlugin`](#find-leaks-while-you-develop-devplugin)
|
|
875
|
+
- [Put them together](#put-them-together)
|
|
876
|
+
- [Use them in any order](#use-them-in-any-order)
|
|
877
|
+
- [See what is not a plugin](#see-what-is-not-a-plugin)
|
|
878
|
+
- [Write your own plugin](#write-your-own-plugin)
|
|
879
|
+
|
|
831
880
|
## Use a plugin
|
|
832
881
|
|
|
833
882
|
A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns. Most plugins return the same bus, so you can chain them: `createBus().use(a).use(b)`. A few return something of their own (a registry, an app, a `disconnect` function): keep it.
|
|
834
883
|
|
|
835
884
|
In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
|
|
836
885
|
|
|
886
|
+
## Pick a plugin by task
|
|
887
|
+
|
|
888
|
+
Find your need in the left column. No row fits: you need no plugin.
|
|
889
|
+
|
|
890
|
+
| I need | Use |
|
|
891
|
+
| ------ | --- |
|
|
892
|
+
| A store, client or logger on every handler | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) |
|
|
893
|
+
| `race`, `all` or `delay` without passing `ctx` | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) |
|
|
894
|
+
| A test that replaces a handler | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) |
|
|
895
|
+
| One domain mounted and unmounted as a unit | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) |
|
|
896
|
+
| Fields for one action or module only | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) |
|
|
897
|
+
| Tasks that stop when a request, socket or query is cancelled | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) |
|
|
898
|
+
| One listener for a group of events | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) |
|
|
899
|
+
| A callback for a state manager that emits | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) |
|
|
900
|
+
| A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
|
|
901
|
+
| Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
|
|
902
|
+
| Warnings about leaks while you develop | [`devPlugin`](#find-leaks-while-you-develop-devplugin) |
|
|
903
|
+
|
|
837
904
|
## Main plugins
|
|
838
905
|
|
|
839
906
|
These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
|
|
@@ -859,6 +926,8 @@ const bus = createBus().use(contextPlugin({ store }));
|
|
|
859
926
|
bus.on("save", (draft, { store }) => store.put(draft));
|
|
860
927
|
```
|
|
861
928
|
|
|
929
|
+
A client that must stop with the task, such as an `axios` instance, goes in as `fromSignal((signal) => axios.create({ signal }))` from `nanoeffects/signal`: it is built from the signal of each task, when the task reads it. More in [Build a field from the signal of each task](context.md#build-a-field-from-the-signal-of-each-task).
|
|
930
|
+
|
|
862
931
|
### Use `race` and `delay` without `ctx`: `effectsPlugin`
|
|
863
932
|
|
|
864
933
|
Effects such as `race`, `all`, `take` and `delay` are bound to the task, so a handler calls `race({ … })` and not `race(ctx, { … })`. Your own functions that take `{ signal }` first work too. More in [Effects](effects.md#put-selected-effects-on-ctx).
|
|
@@ -874,7 +943,7 @@ const bus = createBus().use(effects);
|
|
|
874
943
|
|
|
875
944
|
bus.on("load", async (id, { race, delay }) => {
|
|
876
945
|
const result = await race({
|
|
877
|
-
data: (
|
|
946
|
+
data: (child) => api.get(id, child),
|
|
878
947
|
timeout: () => delay(5000),
|
|
879
948
|
});
|
|
880
949
|
});
|
|
@@ -911,7 +980,7 @@ const bus = createBus();
|
|
|
911
980
|
// modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
|
|
912
981
|
const app = bus.use(modulePlugin([userModule]));
|
|
913
982
|
|
|
914
|
-
app.unmount(userModule); // its listeners, bound
|
|
983
|
+
app.unmount(userModule); // its listeners, bound actions and tasks are gone
|
|
915
984
|
```
|
|
916
985
|
|
|
917
986
|
## Extra plugins
|
|
@@ -921,7 +990,7 @@ Add one of these when you meet its need. None of them is required.
|
|
|
921
990
|
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
922
991
|
| ------ | ----------------- | ------ | ----------------- |
|
|
923
992
|
| [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) | Its own fields for one action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
|
|
924
|
-
| [`signalPlugin`](#tie-an-emit-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
|
|
993
|
+
| [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks, and `on(event, handler, { signal })` that removes the listener, when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
|
|
925
994
|
| [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) | `on` that listens to a group of events which a function picks. | `nanoeffects/match` | The same bus, typed with the new overloads |
|
|
926
995
|
| [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) | A callback that emits an action, to give to a state manager. | `nanoeffects/actions` | The same bus, with `emitter` |
|
|
927
996
|
| [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) | `traceId` and `traceGroupId` for a trace: the root of an emitter, a flow, a named listener. | `nanoeffects/trace` | The same bus |
|
|
@@ -937,10 +1006,10 @@ import { createBus } from "nanoeffects";
|
|
|
937
1006
|
import { createAction } from "nanoeffects/actions";
|
|
938
1007
|
import { scopePlugin } from "nanoeffects/scope";
|
|
939
1008
|
|
|
940
|
-
// scopePlugin — makes the scope of
|
|
1009
|
+
// scopePlugin — makes the scope of an action work on this bus
|
|
941
1010
|
const bus = createBus().use(scopePlugin());
|
|
942
1011
|
|
|
943
|
-
// the last argument: fields of this
|
|
1012
|
+
// the last argument: fields of this action only
|
|
944
1013
|
const getUser = createAction("user/get", (id, { api }) => api.getUser(id), {
|
|
945
1014
|
api,
|
|
946
1015
|
});
|
|
@@ -949,25 +1018,41 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
|
949
1018
|
bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
|
|
950
1019
|
```
|
|
951
1020
|
|
|
952
|
-
### Tie an emit to an outside signal: `signalPlugin`
|
|
1021
|
+
### Tie an emit or a listener to an outside signal: `signalPlugin`
|
|
953
1022
|
|
|
954
|
-
A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query and
|
|
1023
|
+
A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
|
|
955
1024
|
|
|
956
1025
|
```js
|
|
957
1026
|
import { createBus } from "nanoeffects";
|
|
958
1027
|
import { createAction } from "nanoeffects/actions";
|
|
959
1028
|
import { signalPlugin } from "nanoeffects/signal";
|
|
960
1029
|
|
|
961
|
-
// signalPlugin — emit takes { signal } for
|
|
1030
|
+
// signalPlugin — emit takes { signal } for an action, a job or an action
|
|
962
1031
|
const bus = createBus().use(signalPlugin());
|
|
963
1032
|
|
|
964
1033
|
const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
|
|
965
1034
|
|
|
966
1035
|
const controller = new AbortController();
|
|
967
|
-
bus.emit(getUser("1"), { signal: controller.signal }); // runs the
|
|
1036
|
+
bus.emit(getUser("1"), { signal: controller.signal }); // runs the action
|
|
968
1037
|
controller.abort(); // ctx.signal of the handler aborts with it
|
|
969
1038
|
```
|
|
970
1039
|
|
|
1040
|
+
The same signal can end a listener. `on` and `once` take `{ signal }` as their options, as `addEventListener` does, so code that has a signal keeps no `unbind` function. The listener is removed when the signal aborts; a handler that already runs is not aborted. A signal that is aborted already adds no listener.
|
|
1041
|
+
|
|
1042
|
+
```js
|
|
1043
|
+
import { createBus } from "nanoeffects";
|
|
1044
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
1045
|
+
|
|
1046
|
+
// signalPlugin — on and once take { signal } in their options
|
|
1047
|
+
const bus = createBus().use(signalPlugin());
|
|
1048
|
+
|
|
1049
|
+
const controller = new AbortController();
|
|
1050
|
+
bus.on("saved", (draft) => console.log(draft), { signal: controller.signal }); // listens until the abort
|
|
1051
|
+
controller.abort(); // the listener is gone: bus.get("saved") is empty
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
Without the plugin the `signal` option is ignored and the listener stays. `off(event, handler)` removes a listener but does not end its link to the signal; call the function that `on` returned for that.
|
|
1055
|
+
|
|
971
1056
|
### Name the origin and the flow of a trace: `tracePlugin`
|
|
972
1057
|
|
|
973
1058
|
With a tracer on the bus, the plugin lets a trace say which subscription began a chain (`traceId` of `useEmitter` and `bus.emitter`), follow one flow by a `traceGroupId`, and show which listener ran (`bus.on(event, handler, { traceId })`). Without the plugin the options are ignored. More in [Tracing](tracing.md#show-which-subscription-began-a-chain).
|
|
@@ -1080,7 +1165,7 @@ import { scopePlugin } from "nanoeffects/scope";
|
|
|
1080
1165
|
export const bus = createBus()
|
|
1081
1166
|
.use(contextPlugin({ store })) // ctx.store on every task
|
|
1082
1167
|
.use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
|
|
1083
|
-
.use(scopePlugin()) // scopes for
|
|
1168
|
+
.use(scopePlugin()) // scopes for actions, jobs, modules and emit
|
|
1084
1169
|
.use(matchPlugin()) // bus.on takes a function that picks the events
|
|
1085
1170
|
.use(emitterPlugin()); // bus.emitter(creator)
|
|
1086
1171
|
|
|
@@ -1121,7 +1206,7 @@ These connect the bus to something, but are not used with `bus.use`:
|
|
|
1121
1206
|
- `createEffectsMiddleware(bus, { filter })` from `nanoeffects/redux` is a Redux middleware for a bus that exists. See [Stores](stores.md#wire-redux-toolkit-to-a-bus).
|
|
1122
1207
|
- `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
|
|
1123
1208
|
- `extendBus(bus, Base)` from `nanoeffects/oop` gives a class the methods of a bus, with the subscriptions of each instance ended by `dispose()`. It reads the bus as it is, so use the plugins first. See [Use the bus from a class](oop.md).
|
|
1124
|
-
- `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable. See [Observable](observable.md).
|
|
1209
|
+
- `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable, and `events(bus, event)` into a `for await` loop. See [Observable](observable.md).
|
|
1125
1210
|
|
|
1126
1211
|
## Write your own plugin
|
|
1127
1212
|
|
|
@@ -1700,6 +1785,18 @@ Use this page for retry, timeouts, waiting, or parallel work — not as the firs
|
|
|
1700
1785
|
|
|
1701
1786
|
An abort only raises a flag. Your code decides to stop. Start with `retry` / `timeout` on a handler (see [Guide §5](guide.md#5-add-retry-and-timeout)).
|
|
1702
1787
|
|
|
1788
|
+
## On this page
|
|
1789
|
+
|
|
1790
|
+
- [Wrap a handler with retry and timeout](#wrap-a-handler-with-retry-and-timeout)
|
|
1791
|
+
- [Options for retry, timeout, and poll](#options-for-retry-timeout-and-poll)
|
|
1792
|
+
- [Back off between retries](#back-off-between-retries)
|
|
1793
|
+
- [Put selected effects on `ctx`](#put-selected-effects-on-ctx)
|
|
1794
|
+
- [Tie a promise to the task signal](#tie-a-promise-to-the-task-signal)
|
|
1795
|
+
- [Run cleanup on abort](#run-cleanup-on-abort)
|
|
1796
|
+
- [Wait for the next event with `take`](#wait-for-the-next-event-with-take)
|
|
1797
|
+
- [Run several starters together](#run-several-starters-together)
|
|
1798
|
+
- [Pass `ctx` outside a task](#pass-ctx-outside-a-task)
|
|
1799
|
+
|
|
1703
1800
|
## Wrap a handler with retry and timeout
|
|
1704
1801
|
|
|
1705
1802
|
`retry`, `timeout`, and `poll` have two forms:
|
|
@@ -1745,9 +1842,7 @@ bus.on("load", async (id, ctx) => {
|
|
|
1745
1842
|
() => fetchUser(id, { signal: ctx.signal }),
|
|
1746
1843
|
{ times: 4 },
|
|
1747
1844
|
);
|
|
1748
|
-
const data = await timeout(ctx, 5000, (
|
|
1749
|
-
return fetchData(id, { signal });
|
|
1750
|
-
});
|
|
1845
|
+
const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
|
|
1751
1846
|
await poll(ctx, () => refresh(), 10_000);
|
|
1752
1847
|
ctx.emit("ready", { user, data });
|
|
1753
1848
|
});
|
|
@@ -1856,7 +1951,8 @@ const bus = createBus().use(effects);
|
|
|
1856
1951
|
bus.on("load", async (id, { race, all, delay }) => {
|
|
1857
1952
|
// race — same as race(ctx, …)
|
|
1858
1953
|
const result = await race({
|
|
1859
|
-
|
|
1954
|
+
// child — the context of this starter; it has the `signal`, so the request stops with the race
|
|
1955
|
+
data: (child) => fetchData(id, child),
|
|
1860
1956
|
// a starter gets its own child with the same bound effects, so delay(5000) stops with the race
|
|
1861
1957
|
timeout: ({ delay }) => delay(5000),
|
|
1862
1958
|
});
|
|
@@ -1864,8 +1960,8 @@ bus.on("load", async (id, { race, all, delay }) => {
|
|
|
1864
1960
|
|
|
1865
1961
|
// all — same as all(ctx, …)
|
|
1866
1962
|
const { user, posts } = await all({
|
|
1867
|
-
user: (
|
|
1868
|
-
posts: (
|
|
1963
|
+
user: (child) => fetchUser(id, child),
|
|
1964
|
+
posts: (child) => fetchPosts(id, child),
|
|
1869
1965
|
});
|
|
1870
1966
|
});
|
|
1871
1967
|
```
|
|
@@ -1988,8 +2084,8 @@ const bus = createBus();
|
|
|
1988
2084
|
|
|
1989
2085
|
bus.on("open", async (id, ctx) => {
|
|
1990
2086
|
const { user, posts } = await allSettled(ctx, {
|
|
1991
|
-
user: (
|
|
1992
|
-
posts: (
|
|
2087
|
+
user: (child) => fetchUser(id, child),
|
|
2088
|
+
posts: (child) => fetchPosts(id, child),
|
|
1993
2089
|
});
|
|
1994
2090
|
|
|
1995
2091
|
if (user.status === "fulfilled") {
|
|
@@ -2095,7 +2191,7 @@ bus.on("sync", async (ids, ctx) => {
|
|
|
2095
2191
|
The result has the same order and shape as without the option. If one starter of `all` fails, the running ones are cancelled and the waiting ones are never called. `allSettled` goes on after a failure. When `ctx` aborts, both stop and start nothing more. A value that is not a positive integer (`0`, `-1`, `NaN`, `1.5`) throws a `RangeError` before anything starts; `Infinity` means no limit. `race` has no `concurrency`: it ends at the first result, so a starter that waited for a free slot would never run. The form without `ctx` takes no options, because a second argument would look like the form with `ctx`, so a group starter inside another group takes none; write `(child) => all(child, effects, { concurrency: 5 })` there.
|
|
2096
2192
|
|
|
2097
2193
|
|
|
2098
|
-
**A starter gets the context of its own child task.** Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the race ends, `child` is aborted and stops exactly this effect. The outer `ctx` is only for `race` itself. Your own API takes the signal in its options
|
|
2194
|
+
**A starter gets the context of its own child task.** Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the race ends, `child` is aborted and stops exactly this effect. The outer `ctx` is only for `race` itself. Your own API takes the signal in its options, and `child` has a `signal`, so pass it as the options: `(child) => fetchUser(id, child)` (`fetch` reads only `signal` from it). The same holds for `all`, `allSettled` and `timeout`.
|
|
2099
2195
|
|
|
2100
2196
|
If `ctx` is `{}` or a bare `{ signal }` (outside a task), `child` has only `signal` and `abort`, so it fits `delay` and `poll` but not `take`, which needs a bus.
|
|
2101
2197
|
|
|
@@ -2160,6 +2256,32 @@ A chat with two entities and three features, jobs under policies and effects, an
|
|
|
2160
2256
|
|
|
2161
2257
|
A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
|
|
2162
2258
|
|
|
2259
|
+
## Pick the word: event, action, job, module
|
|
2260
|
+
|
|
2261
|
+
Four names, one idea each. Take the first row that fits.
|
|
2262
|
+
|
|
2263
|
+
| Word | What it is | Use it when |
|
|
2264
|
+
| ---- | ---------- | ----------- |
|
|
2265
|
+
| Event | A name and a payload; `createAction("x")` with no handler. | The UI or a handler tells that something happened. |
|
|
2266
|
+
| Action with a handler | The same, plus work: `createAction("x", handler)`. `emit` returns the result. | The caller needs a value back. |
|
|
2267
|
+
| Job | An action with a handler that also emits `pending`, `success`, `fail` and `cancel`. | The UI shows a loading state. |
|
|
2268
|
+
| Module | A group of listeners, handlers and tasks, mounted and unmounted with one call. | One domain must start and stop as a unit. |
|
|
2269
|
+
|
|
2270
|
+
Start with events. Add a handler when you need a result, a job when you need a status, a module when you need a unit.
|
|
2271
|
+
|
|
2272
|
+
## On this page
|
|
2273
|
+
|
|
2274
|
+
- [Pick the word: event, action, job, module](#pick-the-word-event-action-job-module)
|
|
2275
|
+
- [Return a value from `emit` with a handler](#return-a-value-from-emit-with-a-handler)
|
|
2276
|
+
- [Describe events, handlers and jobs](#describe-events-handlers-and-jobs)
|
|
2277
|
+
- [Actions](#actions)
|
|
2278
|
+
- [Bind a handler later](#bind-a-handler-later)
|
|
2279
|
+
- [Say what a bound handler returns](#say-what-a-bound-handler-returns)
|
|
2280
|
+
- [Jobs](#jobs)
|
|
2281
|
+
- [Show a loading state with a job](#show-a-loading-state-with-a-job)
|
|
2282
|
+
- [Mount domains with modules](#mount-domains-with-modules)
|
|
2283
|
+
- [Give an action or a module its own fields](#give-an-action-or-a-module-its-own-fields)
|
|
2284
|
+
|
|
2163
2285
|
## Return a value from `emit` with a handler
|
|
2164
2286
|
|
|
2165
2287
|
Give `createAction` a handler, and `emit` runs it and returns its result. There is one creator for an event and for an action with work behind it; the handler is the only difference.
|
|
@@ -2314,7 +2436,7 @@ On a bus without an event map you can say it where you call: `const id = bus.emi
|
|
|
2314
2436
|
- `pending` / `success` / `fail` / `cancel` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/pending`, `<type>/success`, `<type>/fail` or `<type>/cancel`.
|
|
2315
2437
|
- `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, give `createAction` the handler.
|
|
2316
2438
|
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `actions.handle`). `success` and `fail` are not sent after abort; `cancel` is, at the moment of the abort.
|
|
2317
|
-
- A job without a handler is not an outcome: `emit` throws `No handler for
|
|
2439
|
+
- A job without a handler is not an outcome: `emit` throws `No handler for action "..."` and nothing is sent. An action without a handler is an event and does not throw.
|
|
2318
2440
|
- Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
|
|
2319
2441
|
- The outcomes are events like any other: with `onUnhandled` set on the bus, it is called for a `pending` or a `cancel` that nobody listens to.
|
|
2320
2442
|
|
|
@@ -2476,7 +2598,7 @@ If the `setup` of one module throws, the modules of that call that were already
|
|
|
2476
2598
|
- **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
|
|
2477
2599
|
- **Registry.** If `bus.use(actionsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.actions` and `app.actions`.
|
|
2478
2600
|
- A disposed app cannot mount modules.
|
|
2479
|
-
- **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `
|
|
2601
|
+
- **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `actions`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
|
|
2480
2602
|
- **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
|
|
2481
2603
|
- Module context = task context plus `handle`, `use`, and `spawn`:
|
|
2482
2604
|
- `handle` — give an action a handler (one that has none, or an override).
|
|
@@ -3198,11 +3320,24 @@ A handler gets the store from `ctx.store` and calls a mutation, whatever it hand
|
|
|
3198
3320
|
---
|
|
3199
3321
|
Source: docs/query-libraries.md
|
|
3200
3322
|
|
|
3201
|
-
# Use nanoeffects with React Query and
|
|
3323
|
+
# Use nanoeffects with React Query, SWR and RTK Query
|
|
3202
3324
|
|
|
3203
|
-
Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query)
|
|
3325
|
+
Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query), [SWR](https://swr.vercel.app) or [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one action that the libraries call, then three recipes for the parts around it: a client that stops with the request, a live stream into the cache, and a refetch on a bus event.
|
|
3204
3326
|
|
|
3205
|
-
You need a React project, `nanoeffects`, and one of the
|
|
3327
|
+
You need a React project, `nanoeffects`, and one of the libraries. `api` below is your own client: replace it with your requests.
|
|
3328
|
+
|
|
3329
|
+
## On this page
|
|
3330
|
+
|
|
3331
|
+
- [Decide who owns what](#decide-who-owns-what)
|
|
3332
|
+
- [Make the action](#make-the-action)
|
|
3333
|
+
- [Call it from React Query](#call-it-from-react-query)
|
|
3334
|
+
- [Call it from SWR](#call-it-from-swr)
|
|
3335
|
+
- [Let the bus know the result](#let-the-bus-know-the-result)
|
|
3336
|
+
- [Call it from RTK Query](#call-it-from-rtk-query)
|
|
3337
|
+
- [Give the client its signal once](#give-the-client-its-signal-once)
|
|
3338
|
+
- [Push a live stream into the cache](#push-a-live-stream-into-the-cache)
|
|
3339
|
+
- [Refetch queries when the bus says so](#refetch-queries-when-the-bus-says-so)
|
|
3340
|
+
- [Know the limits](#know-the-limits)
|
|
3206
3341
|
|
|
3207
3342
|
## Decide who owns what
|
|
3208
3343
|
|
|
@@ -3263,7 +3398,7 @@ import { getUser } from "./user";
|
|
|
3263
3398
|
export function Profile({ id }) {
|
|
3264
3399
|
const { isLoading, data, error } = useQuery({
|
|
3265
3400
|
queryKey: ["user", id],
|
|
3266
|
-
// emit — runs the
|
|
3401
|
+
// emit — runs the action; { signal } ties its task to the signal of React Query
|
|
3267
3402
|
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
3268
3403
|
});
|
|
3269
3404
|
|
|
@@ -3315,9 +3450,264 @@ export const userLoaded = createAction("user/loaded");
|
|
|
3315
3450
|
|
|
3316
3451
|
Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the action with `createJob` here: a job handles the error itself and resolves with `undefined`, so React Query would never see the failure. Use a job when nothing waits for the result and the app shows a loading state from the bus, see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job).
|
|
3317
3452
|
|
|
3453
|
+
## Call it from RTK Query
|
|
3454
|
+
|
|
3455
|
+
<!-- docs-check: new -->
|
|
3456
|
+
|
|
3457
|
+
RTK Query gives the `queryFn` of an endpoint a `signal` in its second argument, so the call is the same as with React Query: pass it to `emit`. This page does not set up the store; see [Stores](stores.md) for that.
|
|
3458
|
+
|
|
3459
|
+
```js
|
|
3460
|
+
// bus.js
|
|
3461
|
+
import { createBus } from "nanoeffects";
|
|
3462
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
3463
|
+
|
|
3464
|
+
// signalPlugin — lets emit take { signal }
|
|
3465
|
+
export const bus = createBus().use(signalPlugin());
|
|
3466
|
+
```
|
|
3467
|
+
|
|
3468
|
+
```js
|
|
3469
|
+
// user.js
|
|
3470
|
+
import { createAction } from "nanoeffects/actions";
|
|
3471
|
+
|
|
3472
|
+
// createAction with a handler — emit returns what the handler returns
|
|
3473
|
+
export const getUser = createAction("user/get", (id, { signal }) =>
|
|
3474
|
+
api.getUser(id, { signal }),
|
|
3475
|
+
);
|
|
3476
|
+
```
|
|
3477
|
+
|
|
3478
|
+
```js
|
|
3479
|
+
// api-slice.js
|
|
3480
|
+
import { createApi, fakeBaseQuery } from "@reduxjs/toolkit/query/react";
|
|
3481
|
+
import { bus } from "./bus";
|
|
3482
|
+
import { getUser } from "./user";
|
|
3483
|
+
|
|
3484
|
+
export const userApi = createApi({
|
|
3485
|
+
reducerPath: "userApi",
|
|
3486
|
+
baseQuery: fakeBaseQuery(), // RTK Query — every endpoint brings its own queryFn
|
|
3487
|
+
endpoints: (build) => ({
|
|
3488
|
+
getUser: build.query({
|
|
3489
|
+
// emit — runs the action; { signal } ties its task to the request of RTK Query
|
|
3490
|
+
queryFn: async (id, { signal }) => {
|
|
3491
|
+
const user = await bus.emit(getUser(id), { signal });
|
|
3492
|
+
return { data: user };
|
|
3493
|
+
},
|
|
3494
|
+
}),
|
|
3495
|
+
}),
|
|
3496
|
+
});
|
|
3497
|
+
```
|
|
3498
|
+
|
|
3499
|
+
## Give the client its signal once
|
|
3500
|
+
|
|
3501
|
+
<!-- docs-check: new -->
|
|
3502
|
+
|
|
3503
|
+
Problem: the action passes `signal` to every call of `api`, and one forgotten `{ signal }` leaves a request running after the query is gone. `fromSignal` builds the client from the signal of each task, so the handler cannot forget it. Use it when many actions share one client. A single action is simpler as it is in [Make the action](#make-the-action).
|
|
3504
|
+
|
|
3505
|
+
`fromSignal` marks a field of `contextPlugin`. The client is made when a handler first reads `ctx.http`, from the `ctx.signal` of that task. More in [Context fields](context.md#build-a-field-from-the-signal-of-each-task).
|
|
3506
|
+
|
|
3507
|
+
```js
|
|
3508
|
+
// bus.js
|
|
3509
|
+
import axios from "axios";
|
|
3510
|
+
import { createBus } from "nanoeffects";
|
|
3511
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
3512
|
+
import { fromSignal, signalPlugin } from "nanoeffects/signal";
|
|
3513
|
+
|
|
3514
|
+
// fromSignal — http is a new axios client for each task, bound to the signal of that task
|
|
3515
|
+
const http = fromSignal((signal) => axios.create({ baseURL: "/api", signal }));
|
|
3516
|
+
|
|
3517
|
+
// contextPlugin — every handler gets ctx.http
|
|
3518
|
+
const context = contextPlugin({ http });
|
|
3519
|
+
|
|
3520
|
+
// signalPlugin — emit takes { signal }, so the signal of the query reaches the task
|
|
3521
|
+
export const bus = createBus().use(signalPlugin()).use(context);
|
|
3522
|
+
```
|
|
3523
|
+
|
|
3524
|
+
```js
|
|
3525
|
+
// user.js
|
|
3526
|
+
import { createAction } from "nanoeffects/actions";
|
|
3527
|
+
|
|
3528
|
+
// the handler never sees a signal: ctx.http stops when the task aborts
|
|
3529
|
+
export const getUser = createAction("user/get", async (id, { http }) => {
|
|
3530
|
+
const response = await http.get(`/users/${id}`);
|
|
3531
|
+
return response.data;
|
|
3532
|
+
});
|
|
3533
|
+
```
|
|
3534
|
+
|
|
3535
|
+
```jsx
|
|
3536
|
+
// Profile.jsx
|
|
3537
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3538
|
+
import { bus } from "./bus";
|
|
3539
|
+
import { getUser } from "./user";
|
|
3540
|
+
|
|
3541
|
+
export function Profile({ id }) {
|
|
3542
|
+
const { data } = useQuery({
|
|
3543
|
+
queryKey: ["user", id],
|
|
3544
|
+
// emit — the signal of React Query aborts the task, and with it the request of ctx.http
|
|
3545
|
+
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
3546
|
+
});
|
|
3547
|
+
|
|
3548
|
+
return <p>{data?.name}</p>;
|
|
3549
|
+
}
|
|
3550
|
+
```
|
|
3551
|
+
|
|
3552
|
+
## Push a live stream into the cache
|
|
3553
|
+
|
|
3554
|
+
<!-- docs-check: new -->
|
|
3555
|
+
|
|
3556
|
+
Problem: prices arrive from a socket, and the component should show the newest one without a refetch. The socket code emits an event on the bus; the component reads those events with `events` and writes each into the cache. Both libraries have an entry point for pushed data: `setQueryData` in React Query and `useSWRSubscription` in SWR.
|
|
3557
|
+
|
|
3558
|
+
`events` gives the payloads to a `for await` loop. `buffer: 1` keeps only the newest price while a write is running. The abort in the cleanup ends the loop and removes the listener. More in [Read an event with a `for await` loop](observable.md#read-an-event-with-a-for-await-loop).
|
|
3559
|
+
|
|
3560
|
+
```js
|
|
3561
|
+
// bus.js
|
|
3562
|
+
import { createBus } from "nanoeffects";
|
|
3563
|
+
|
|
3564
|
+
// createBus — the bus the socket code and the components share
|
|
3565
|
+
export const bus = createBus();
|
|
3566
|
+
```
|
|
3567
|
+
|
|
3568
|
+
```js
|
|
3569
|
+
// prices.js
|
|
3570
|
+
import { createAction } from "nanoeffects/actions";
|
|
3571
|
+
|
|
3572
|
+
// createAction — the event the socket code emits for every new price
|
|
3573
|
+
export const priceChanged = createAction("price/changed");
|
|
3574
|
+
```
|
|
3575
|
+
|
|
3576
|
+
```jsx
|
|
3577
|
+
// Price.jsx
|
|
3578
|
+
import { useEffect } from "react";
|
|
3579
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3580
|
+
import { events } from "nanoeffects/observable";
|
|
3581
|
+
import { bus } from "./bus";
|
|
3582
|
+
import { priceChanged } from "./prices";
|
|
3583
|
+
|
|
3584
|
+
export function Price({ symbol }) {
|
|
3585
|
+
const queryClient = useQueryClient();
|
|
3586
|
+
const { data } = useQuery({
|
|
3587
|
+
queryKey: ["price", symbol],
|
|
3588
|
+
queryFn: () => api.getPrice(symbol), // the first value; the stream keeps it fresh
|
|
3589
|
+
});
|
|
3590
|
+
|
|
3591
|
+
useEffect(() => {
|
|
3592
|
+
const controller = new AbortController();
|
|
3593
|
+
// events — the newest price event, one at a time
|
|
3594
|
+
const prices = events(bus, priceChanged, {
|
|
3595
|
+
signal: controller.signal,
|
|
3596
|
+
buffer: 1,
|
|
3597
|
+
});
|
|
3598
|
+
|
|
3599
|
+
async function follow() {
|
|
3600
|
+
for await (const price of prices) {
|
|
3601
|
+
if (price.symbol === symbol) {
|
|
3602
|
+
queryClient.setQueryData(["price", symbol], price.value); // write into the cache
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
follow().catch((error) => {
|
|
3608
|
+
if (!controller.signal.aborted) throw error; // the abort ends the loop; anything else is a bug
|
|
3609
|
+
});
|
|
3610
|
+
|
|
3611
|
+
return () => controller.abort(); // the component left: the loop ends, the listener goes
|
|
3612
|
+
}, [queryClient, symbol]);
|
|
3613
|
+
|
|
3614
|
+
return <p>{data}</p>;
|
|
3615
|
+
}
|
|
3616
|
+
```
|
|
3617
|
+
|
|
3618
|
+
SWR does the same with `useSWRSubscription` from `swr/subscription`. Its subscribe function gets `next` to push a value and returns the cleanup:
|
|
3619
|
+
|
|
3620
|
+
```jsx
|
|
3621
|
+
// Price.jsx
|
|
3622
|
+
import useSWRSubscription from "swr/subscription";
|
|
3623
|
+
import { events } from "nanoeffects/observable";
|
|
3624
|
+
import { bus } from "./bus";
|
|
3625
|
+
import { priceChanged } from "./prices";
|
|
3626
|
+
|
|
3627
|
+
export function Price({ symbol }) {
|
|
3628
|
+
const { data } = useSWRSubscription(["price", symbol], (_key, { next }) => {
|
|
3629
|
+
const controller = new AbortController();
|
|
3630
|
+
// events — the newest price event, one at a time
|
|
3631
|
+
const prices = events(bus, priceChanged, {
|
|
3632
|
+
signal: controller.signal,
|
|
3633
|
+
buffer: 1,
|
|
3634
|
+
});
|
|
3635
|
+
|
|
3636
|
+
async function follow() {
|
|
3637
|
+
for await (const price of prices) {
|
|
3638
|
+
if (price.symbol === symbol) {
|
|
3639
|
+
next(null, price.value); // SWR — push the value to the hook
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
follow().catch((error) => {
|
|
3645
|
+
if (!controller.signal.aborted) next(error);
|
|
3646
|
+
});
|
|
3647
|
+
|
|
3648
|
+
return () => controller.abort(); // SWR calls this when the last component of the key leaves
|
|
3649
|
+
});
|
|
3650
|
+
|
|
3651
|
+
return <p>{data}</p>;
|
|
3652
|
+
}
|
|
3653
|
+
```
|
|
3654
|
+
|
|
3655
|
+
## Refetch queries when the bus says so
|
|
3656
|
+
|
|
3657
|
+
<!-- docs-check: new -->
|
|
3658
|
+
|
|
3659
|
+
Problem: a save in one part of the app should refresh the lists in another. The save emits an event, and the component that owns the queries listens for it. One `AbortController` ends every listener of the component, instead of one `unbind` for each. For a single listener, `return bus.on(…)` from the effect is shorter.
|
|
3660
|
+
|
|
3661
|
+
This needs `signalPlugin`, because `{ signal }` in the options of `on` is read by the plugin. Without it the listeners stay after the component leaves.
|
|
3662
|
+
|
|
3663
|
+
```js
|
|
3664
|
+
// bus.js
|
|
3665
|
+
import { createBus } from "nanoeffects";
|
|
3666
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
3667
|
+
|
|
3668
|
+
// signalPlugin — on takes { signal } and removes the listener when it aborts
|
|
3669
|
+
export const bus = createBus().use(signalPlugin());
|
|
3670
|
+
```
|
|
3671
|
+
|
|
3672
|
+
```js
|
|
3673
|
+
// user-events.js
|
|
3674
|
+
import { createAction } from "nanoeffects/actions";
|
|
3675
|
+
|
|
3676
|
+
// createAction — events the save code emits
|
|
3677
|
+
export const userSaved = createAction("user/saved");
|
|
3678
|
+
export const userDeleted = createAction("user/deleted");
|
|
3679
|
+
```
|
|
3680
|
+
|
|
3681
|
+
```jsx
|
|
3682
|
+
// UserList.jsx
|
|
3683
|
+
import { useEffect } from "react";
|
|
3684
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3685
|
+
import { bus } from "./bus";
|
|
3686
|
+
import { userDeleted, userSaved } from "./user-events";
|
|
3687
|
+
|
|
3688
|
+
export function UserList() {
|
|
3689
|
+
const queryClient = useQueryClient();
|
|
3690
|
+
const { data } = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() });
|
|
3691
|
+
|
|
3692
|
+
useEffect(() => {
|
|
3693
|
+
const controller = new AbortController();
|
|
3694
|
+
const { signal } = controller;
|
|
3695
|
+
const refresh = () => queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
3696
|
+
|
|
3697
|
+
// on with { signal } — both listeners go when the component leaves
|
|
3698
|
+
bus.on(userSaved, refresh, { signal });
|
|
3699
|
+
bus.on(userDeleted, refresh, { signal });
|
|
3700
|
+
|
|
3701
|
+
return () => controller.abort();
|
|
3702
|
+
}, [queryClient]);
|
|
3703
|
+
|
|
3704
|
+
return <ul>{data?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
|
|
3705
|
+
}
|
|
3706
|
+
```
|
|
3707
|
+
|
|
3318
3708
|
## Know the limits
|
|
3319
3709
|
|
|
3320
|
-
- **Without the plugin, `{ signal }` is ignored
|
|
3710
|
+
- **Without the plugin, `{ signal }` is ignored,** in `emit` and in the options of `on`. The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
|
|
3321
3711
|
- **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
|
|
3322
3712
|
- **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Actions that fetch are async, so this only matters for a sync handler that sets up a callback.
|
|
3323
3713
|
- **Give each request its own signal for an event.** For an action with a handler or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
|
|
@@ -3367,7 +3757,7 @@ The action loads a user. It tries the request up to three times, and gives up on
|
|
|
3367
3757
|
import { createAction } from "nanoeffects/actions";
|
|
3368
3758
|
import { retry, timeout } from "nanoeffects/effects";
|
|
3369
3759
|
|
|
3370
|
-
// createAction —
|
|
3760
|
+
// createAction — an action with a handler: emit returns what the handler returns
|
|
3371
3761
|
export const getUser = createAction("user/get", async (id, ctx) => {
|
|
3372
3762
|
// one try: the request, with the signal of timeout so it can stop it
|
|
3373
3763
|
const load = ({ signal }) => api.getUser(id, { signal });
|
|
@@ -3408,7 +3798,7 @@ app.get("/users/:id", async (request, reply) => {
|
|
|
3408
3798
|
});
|
|
3409
3799
|
|
|
3410
3800
|
try {
|
|
3411
|
-
// emit with { signal } — aborting the controller aborts the
|
|
3801
|
+
// emit with { signal } — aborting the controller aborts the action: timeout, retry and the request
|
|
3412
3802
|
const call = getUser(request.params.id);
|
|
3413
3803
|
return await bus.emit(call, { signal: controller.signal });
|
|
3414
3804
|
} catch (error) {
|
|
@@ -3461,7 +3851,7 @@ import { createAction } from "nanoeffects/actions";
|
|
|
3461
3851
|
import { poll } from "nanoeffects/effects";
|
|
3462
3852
|
import { latest } from "nanoeffects/policies";
|
|
3463
3853
|
|
|
3464
|
-
// createAction —
|
|
3854
|
+
// createAction — an action with a handler
|
|
3465
3855
|
// latest — a new subscription stops the old one; key — but only the old one of the same socket
|
|
3466
3856
|
export const watchPrices = createAction(
|
|
3467
3857
|
"prices/watch",
|
|
@@ -3540,6 +3930,16 @@ Use this page when a React screen starts requests on user input and you need the
|
|
|
3540
3930
|
|
|
3541
3931
|
You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
|
|
3542
3932
|
|
|
3933
|
+
## On this page
|
|
3934
|
+
|
|
3935
|
+
- [See what goes wrong without a bus](#see-what-goes-wrong-without-a-bus)
|
|
3936
|
+
- [Wire the bus once](#wire-the-bus-once)
|
|
3937
|
+
- [Connect the form](#connect-the-form)
|
|
3938
|
+
- [Stop running work when the form closes](#stop-running-work-when-the-form-closes)
|
|
3939
|
+
- [Show the last value of an event as state](#show-the-last-value-of-an-event-as-state)
|
|
3940
|
+
- [Keep a debounced handler between renders](#keep-a-debounced-handler-between-renders)
|
|
3941
|
+
- [Where to go next](#where-to-go-next)
|
|
3942
|
+
|
|
3543
3943
|
## See what goes wrong without a bus
|
|
3544
3944
|
|
|
3545
3945
|
A registration form has three problems that `useEffect` and `fetch` leave to you:
|
|
@@ -3611,7 +4011,7 @@ The handlers know nothing about React. A test can emit `username/typed` and read
|
|
|
3611
4011
|
|
|
3612
4012
|
<!-- docs-check: continue -->
|
|
3613
4013
|
|
|
3614
|
-
The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount.
|
|
4014
|
+
The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount. If the component already holds an `AbortSignal`, `bus.on(event, handler, { signal })` ends the listener with it; see [`signalPlugin`](plugins.md#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin).
|
|
3615
4015
|
|
|
3616
4016
|
```jsx
|
|
3617
4017
|
// SignUpForm.jsx
|
|
@@ -3812,7 +4212,7 @@ export const Search = ({ wait }) => {
|
|
|
3812
4212
|
- A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
|
|
3813
4213
|
- `retry`, `timeout` and the other effects: [Effects](effects.md).
|
|
3814
4214
|
- A state library instead of `useState`: [Stores](stores.md).
|
|
3815
|
-
- React Query or SWR instead of your own state: [Use nanoeffects with React Query and
|
|
4215
|
+
- React Query or SWR instead of your own state: [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
|
|
3816
4216
|
|
|
3817
4217
|
---
|
|
3818
4218
|
Source: docs/chat-example.md
|
|
@@ -3833,6 +4233,18 @@ npm run dev
|
|
|
3833
4233
|
|
|
3834
4234
|
`npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
|
|
3835
4235
|
|
|
4236
|
+
## On this page
|
|
4237
|
+
|
|
4238
|
+
- [Read how the server fails](#read-how-the-server-fails)
|
|
4239
|
+
- [See how the files are split](#see-how-the-files-are-split)
|
|
4240
|
+
- [Load a history that must not race](#load-a-history-that-must-not-race)
|
|
4241
|
+
- [Send a message that can fail](#send-a-message-that-can-fail)
|
|
4242
|
+
- [Search where one room is slow](#search-where-one-room-is-slow)
|
|
4243
|
+
- [Wait for a room before marking a result](#wait-for-a-room-before-marking-a-result)
|
|
4244
|
+
- [Poll for new messages](#poll-for-new-messages)
|
|
4245
|
+
- [Know where each value lives](#know-where-each-value-lives)
|
|
4246
|
+
- [Where to go next](#where-to-go-next)
|
|
4247
|
+
|
|
3836
4248
|
## Read how the server fails
|
|
3837
4249
|
|
|
3838
4250
|
The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
|
|
@@ -3867,7 +4279,7 @@ The code is in layers, and a file imports only from the layers below it. The com
|
|
|
3867
4279
|
|
|
3868
4280
|
That rule is why `show-history` exists. Selecting a room must load its history, but `room` does not know `message` and `message` does not know `room`. The feature `show-history` is the one place that knows both: it listens to `rooms/selected` and starts the job `messages/loadHistory`. The same goes for a message that arrives: `receive-messages` emits `incoming/received`, an event that the `message` entity owns and listens to.
|
|
3869
4281
|
|
|
3870
|
-
A folder has its events (`room.ts`, which imports no bus), its work (`room-handlers.ts`), the state it keeps (`room-store.ts`) and a module (`room-module.ts`) that wires them. Another layer reaches an entity through its
|
|
4282
|
+
A folder has its events (`room.ts`, which imports no bus), its work (`room-handlers.ts`), the state it keeps (`room-store.ts`) and a module (`room-module.ts`) that wires them. Another layer reaches an entity through its actions, never through its handlers or its store. All actions live in the events file. An action with no handler says that something happened, and any feature may listen: `rooms/selected` is heard by two. An action with a handler is an order with exactly one handler, as `search/openResult` and `messages/mark` are. The handler is given to `createAction`, so the action works wherever it is called and can return a result, and the module binds it with `module.use` for as long as it is mounted. A job is an action with a handler that also sends its outcomes, but its handler is bound in the module (`module.handle`): a job usually runs under a policy, and the type of its outcomes is exact only when the handler is not part of the type.
|
|
3871
4283
|
|
|
3872
4284
|
The layers come from [Feature-Sliced Design](https://feature-sliced.design/), reduced to the three that an app of this size needs.
|
|
3873
4285
|
|
|
@@ -3883,7 +4295,7 @@ A job sends `pending` when its handler starts and exactly one of `success`, `fai
|
|
|
3883
4295
|
// message.js
|
|
3884
4296
|
import { createJob } from "nanoeffects/actions";
|
|
3885
4297
|
|
|
3886
|
-
// createJob — a
|
|
4298
|
+
// createJob — an action with a handler that also sends pending, success, fail and cancel
|
|
3887
4299
|
export const loadHistory = createJob("messages/loadHistory");
|
|
3888
4300
|
```
|
|
3889
4301
|
|
|
@@ -4014,7 +4426,7 @@ export const goToResult = async (hit, ctx) => {
|
|
|
4014
4426
|
ctx.emit("rooms/selected", hit.roomId);
|
|
4015
4427
|
await loaded;
|
|
4016
4428
|
}
|
|
4017
|
-
// ctx.emit with a
|
|
4429
|
+
// ctx.emit with an action that has a handler — the message entity marks the message
|
|
4018
4430
|
ctx.emit(markMessage(hit.id));
|
|
4019
4431
|
};
|
|
4020
4432
|
```
|
|
@@ -4066,10 +4478,149 @@ Each entity and each feature that has state keeps it in a [Zustand](https://zust
|
|
|
4066
4478
|
|
|
4067
4479
|
## Where to go next
|
|
4068
4480
|
|
|
4069
|
-
- The API of
|
|
4481
|
+
- The API of actions, jobs and modules: [Actions, jobs and modules](actions.md).
|
|
4070
4482
|
- Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
|
|
4071
4483
|
- A smaller project with two domains: [Start with domains](starter.md#start-with-domains). Its two domains do not talk to each other, so it needs no layers.
|
|
4072
4484
|
|
|
4485
|
+
---
|
|
4486
|
+
Source: docs/chat-widget.md
|
|
4487
|
+
|
|
4488
|
+
# Build a chat widget that listens only while it is open
|
|
4489
|
+
|
|
4490
|
+
Use this page when a site has a chat with a bot behind a launcher button, and the chat must cost nothing while it is closed. Closed means no subscriptions on the bus, no polling, no keyboard listener, and no request still running. Opening turns all of it on, and closing turns all of it off in one call.
|
|
4491
|
+
|
|
4492
|
+
The code is in [`examples/chat-widget`](../examples/chat-widget): plain DOM and TypeScript, a fake bot in the browser, no server. Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget), or on your machine:
|
|
4493
|
+
|
|
4494
|
+
```bash
|
|
4495
|
+
cd examples/chat-widget
|
|
4496
|
+
npm install
|
|
4497
|
+
npm run dev
|
|
4498
|
+
```
|
|
4499
|
+
|
|
4500
|
+
It is a level D example: the logic lives in one module, and the page only mounts and unmounts it. For an app with several features see [Build a chat with entities, features, modules and a small server](chat-example.md).
|
|
4501
|
+
|
|
4502
|
+
## Know what happens on open and on close
|
|
4503
|
+
|
|
4504
|
+
| Moment | What happens |
|
|
4505
|
+
| ------ | ------------ |
|
|
4506
|
+
| Open | The module is mounted: the bot handler is subscribed, the operator messages are polled, the `Escape` listener is added. The panel is drawn and listens to the bus. |
|
|
4507
|
+
| The user sends a text | The bot is asked, one text after another, with a time limit and a retry. |
|
|
4508
|
+
| `Escape` | The module emits `chat/closeRequested`; the launcher closes the chat. |
|
|
4509
|
+
| Close | The panel is removed and unsubscribed, the module is unmounted. A request in flight is aborted, the polling stops, the `Escape` listener goes away. |
|
|
4510
|
+
|
|
4511
|
+
## Find the parts in the files
|
|
4512
|
+
|
|
4513
|
+
| File | Job |
|
|
4514
|
+
| ---- | --- |
|
|
4515
|
+
| `src/bus.ts` | The bus, and `modulePlugin()` with no module: the launcher mounts the module itself. |
|
|
4516
|
+
| `src/chat.ts` | The events only: `messageSent`, `messageAdded`, `typingChanged`, `closeRequested`. |
|
|
4517
|
+
| `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
|
|
4518
|
+
| `src/chat-module.ts` | The list of what is alive while the chat is open. |
|
|
4519
|
+
| `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
|
|
4520
|
+
| `src/main.ts` | The launcher: the only file that knows both the module and the panel; it listens for `closeRequested` only while the chat is open. |
|
|
4521
|
+
|
|
4522
|
+
## Bind everything in one module
|
|
4523
|
+
|
|
4524
|
+
A module is the list of what is alive while the chat is open. When it unmounts, the bus undoes every line of it.
|
|
4525
|
+
|
|
4526
|
+
<!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
|
|
4527
|
+
```js
|
|
4528
|
+
// chat-module.js
|
|
4529
|
+
import { createModule } from "nanoeffects/actions";
|
|
4530
|
+
import { queue } from "nanoeffects/policies";
|
|
4531
|
+
import { closeRequested, messageSent } from "./chat";
|
|
4532
|
+
import { askBot, watchOperator } from "./chat-handlers";
|
|
4533
|
+
|
|
4534
|
+
// a task that stays pending until the module unmounts; its signal then aborts
|
|
4535
|
+
const untilAborted = (ctx) =>
|
|
4536
|
+
new Promise((resolve) => {
|
|
4537
|
+
ctx.signal.addEventListener("abort", resolve, { once: true });
|
|
4538
|
+
});
|
|
4539
|
+
|
|
4540
|
+
// a DOM listener that lives as long as the module: `signal` removes it when it aborts
|
|
4541
|
+
const listenToEscape = async (_, ctx) => {
|
|
4542
|
+
const onKey = (event) => {
|
|
4543
|
+
if (event.key === "Escape") {
|
|
4544
|
+
ctx.emit(closeRequested());
|
|
4545
|
+
}
|
|
4546
|
+
};
|
|
4547
|
+
document.addEventListener("keydown", onKey, { signal: ctx.signal });
|
|
4548
|
+
await untilAborted(ctx);
|
|
4549
|
+
};
|
|
4550
|
+
|
|
4551
|
+
export const chatModule = createModule("chat", (module) => {
|
|
4552
|
+
// module.run — runs askBot as a child task of the module, so an unmount aborts it
|
|
4553
|
+
const answer = (text) => module.run(askBot, text);
|
|
4554
|
+
// on + queue — answer the texts one after another, in the order they were sent
|
|
4555
|
+
module.on(messageSent, queue(answer));
|
|
4556
|
+
// spawn — a background task that lives as long as the module
|
|
4557
|
+
module.spawn(watchOperator);
|
|
4558
|
+
module.spawn(listenToEscape);
|
|
4559
|
+
});
|
|
4560
|
+
```
|
|
4561
|
+
|
|
4562
|
+
Three details that make the cleanup complete:
|
|
4563
|
+
|
|
4564
|
+
- **`module.run`, not `askBot` directly.** A handler given to `module.on` runs as a child of whoever emitted the event (here the panel), so unmounting stops listening but does not abort a request that is already running. Running it with `module.run` makes it a child of the module.
|
|
4565
|
+
- **`queue`, not `latest`.** `queue` keeps the order when the user sends two texts fast. [`latest`](policies.md) would abort the first answer, which is wrong for a conversation.
|
|
4566
|
+
- **A DOM listener with `signal`.** `addEventListener` takes a `signal` option and removes the listener when it aborts. The task stays pending until then, because a task that has finished is not aborted again.
|
|
4567
|
+
|
|
4568
|
+
## Open and close from the launcher
|
|
4569
|
+
|
|
4570
|
+
`app.mount` returns the function that unmounts the module. `bus.on` returns the one that unsubscribes. The launcher puts them together with the one that removes the panel into a single `close`.
|
|
4571
|
+
|
|
4572
|
+
<!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
|
|
4573
|
+
```js
|
|
4574
|
+
// main.js
|
|
4575
|
+
import { app, bus } from "./bus";
|
|
4576
|
+
import { closeRequested } from "./chat";
|
|
4577
|
+
import { chatModule } from "./chat-module";
|
|
4578
|
+
import { renderPanel } from "./panel";
|
|
4579
|
+
|
|
4580
|
+
const button = document.querySelector("#chat-launcher");
|
|
4581
|
+
let close = null; // set while the chat is open
|
|
4582
|
+
|
|
4583
|
+
const open = () => {
|
|
4584
|
+
const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
|
|
4585
|
+
const stopPanel = renderPanel(document.body);
|
|
4586
|
+
// the module emits closeRequested on Escape; nothing does while the chat is closed
|
|
4587
|
+
const stopEscape = bus.on(closeRequested, () => close?.());
|
|
4588
|
+
close = () => {
|
|
4589
|
+
stopEscape();
|
|
4590
|
+
stopPanel();
|
|
4591
|
+
stopModule(); // everything the module started ends, a running request included
|
|
4592
|
+
close = null;
|
|
4593
|
+
};
|
|
4594
|
+
};
|
|
4595
|
+
|
|
4596
|
+
button.addEventListener("click", () => {
|
|
4597
|
+
if (close) {
|
|
4598
|
+
close();
|
|
4599
|
+
} else {
|
|
4600
|
+
open();
|
|
4601
|
+
}
|
|
4602
|
+
});
|
|
4603
|
+
```
|
|
4604
|
+
|
|
4605
|
+
While the chat is closed the launcher holds no subscription at all: it only has the click listener of the button, which is a DOM listener, not a listener of the bus.
|
|
4606
|
+
|
|
4607
|
+
## Check that closing cleans up
|
|
4608
|
+
|
|
4609
|
+
1. Open the chat and send a text. The bot answers after about a second. Every third question fails, and the retry asks again.
|
|
4610
|
+
2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
|
|
4611
|
+
3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: every event shows `0`, and so does "all events". Open: each of the four has one listener, and "all events" is `4`. That is the proof that nothing is left subscribed.
|
|
4612
|
+
4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
|
|
4613
|
+
5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
|
|
4614
|
+
6. Open the chat again. The old lines are back, and the polling runs.
|
|
4615
|
+
|
|
4616
|
+
`src/tests/chat-widget-example.test.ts` checks the same points without a browser.
|
|
4617
|
+
|
|
4618
|
+
## Where to go next
|
|
4619
|
+
|
|
4620
|
+
- Show an unread count on the launcher while the chat is closed: a second module that stays mounted and listens only to `chat/messageAdded`.
|
|
4621
|
+
- Replace the polling by a WebSocket in a spawned task: the abort closes it the same way (see [`examples/trades`](../examples/trades) for a socket under `retry` and `latest`).
|
|
4622
|
+
- Watch the whole thing in the [trace](tracing.md): the module, the bot task, the retries and the polling are tasks of one tree.
|
|
4623
|
+
|
|
4073
4624
|
---
|
|
4074
4625
|
Source: docs/vue.md
|
|
4075
4626
|
|
|
@@ -4281,7 +4832,7 @@ Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may
|
|
|
4281
4832
|
|
|
4282
4833
|
To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
|
|
4283
4834
|
|
|
4284
|
-
To give fields to **one**
|
|
4835
|
+
To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
|
|
4285
4836
|
|
|
4286
4837
|
## Type the fields (optional)
|
|
4287
4838
|
|
|
@@ -4305,6 +4856,38 @@ bus.on("save", (draft, { store }) => store.put(draft));
|
|
|
4305
4856
|
- A name that the bus already has is a type error. The fields of the library (`signal`, `emit`, `run`, …) win at runtime, and a field that the context already has is not replaced.
|
|
4306
4857
|
- The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
|
|
4307
4858
|
|
|
4859
|
+
## Build a field from the signal of each task
|
|
4860
|
+
|
|
4861
|
+
Problem: an HTTP client or a database handle should stop when the task is aborted. Without help, every handler passes `ctx.signal` to it by hand, and one forgotten call leaves a request running.
|
|
4862
|
+
|
|
4863
|
+
`fromSignal(make)` from `nanoeffects/signal` marks a field of `contextPlugin` as a value that is built from the signal of the task. It does not run `make` for the bus: it runs it for each task that reads the field, once, with the `ctx.signal` of that task.
|
|
4864
|
+
|
|
4865
|
+
```js
|
|
4866
|
+
import { createBus } from "nanoeffects";
|
|
4867
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
4868
|
+
import { fromSignal } from "nanoeffects/signal";
|
|
4869
|
+
import axios from "axios";
|
|
4870
|
+
|
|
4871
|
+
// contextPlugin — every task gets these fields
|
|
4872
|
+
// fromSignal — http is made from the signal of the task that reads ctx.http
|
|
4873
|
+
const bus = createBus().use(
|
|
4874
|
+
contextPlugin({
|
|
4875
|
+
logger: console, // a plain field: the same object for every task
|
|
4876
|
+
http: fromSignal((signal) => axios.create({ signal })),
|
|
4877
|
+
}),
|
|
4878
|
+
);
|
|
4879
|
+
|
|
4880
|
+
bus.on("user/open", async (id, ctx) => {
|
|
4881
|
+
const response = await ctx.http.get(`/users/${id}`); // aborts with the task
|
|
4882
|
+
ctx.logger.log(response.data);
|
|
4883
|
+
});
|
|
4884
|
+
```
|
|
4885
|
+
|
|
4886
|
+
- `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
|
|
4887
|
+
- A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
|
|
4888
|
+
- The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
|
|
4889
|
+
- Without `contextPlugin` a `fromSignal` marker is only an object.
|
|
4890
|
+
|
|
4308
4891
|
### Name the fields in the event map instead
|
|
4309
4892
|
|
|
4310
4893
|
`createBus<Events>({ context: { store } })` also types the fields, but through the event map: `WithContext` is checker-only and says which fields the handlers of this bus have. Use it when you cannot call `use`, for example for the bus that `createEffectsMiddleware` makes.
|
|
@@ -4329,6 +4912,18 @@ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`,
|
|
|
4329
4912
|
|
|
4330
4913
|
You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
|
|
4331
4914
|
|
|
4915
|
+
## On this page
|
|
4916
|
+
|
|
4917
|
+
- [Turn scopes on](#turn-scopes-on)
|
|
4918
|
+
- [Give an action its own fields](#give-an-action-its-own-fields)
|
|
4919
|
+
- [Bind an effect to the task](#bind-an-effect-to-the-task)
|
|
4920
|
+
- [Replace a field for one call](#replace-a-field-for-one-call)
|
|
4921
|
+
- [Give an action, a job or a module its own fields](#give-an-action-a-job-or-a-module-its-own-fields)
|
|
4922
|
+
- [Which value wins](#which-value-wins)
|
|
4923
|
+
- [Keep a generic effect generic](#keep-a-generic-effect-generic)
|
|
4924
|
+
- [Type the fields (optional)](#type-the-fields-optional)
|
|
4925
|
+
- [Limits](#limits)
|
|
4926
|
+
|
|
4332
4927
|
## Turn scopes on
|
|
4333
4928
|
|
|
4334
4929
|
Scopes are a plugin, so a bus that does not use them pays nothing.
|
|
@@ -4359,7 +4954,7 @@ import { createAction } from "nanoeffects/actions";
|
|
|
4359
4954
|
export const getUser = createAction(
|
|
4360
4955
|
"user/get",
|
|
4361
4956
|
(id, { api, cache }) => {
|
|
4362
|
-
// api and cache are on ctx for this
|
|
4957
|
+
// api and cache are on ctx for this action only
|
|
4363
4958
|
return cache.get(id) ?? api.getUser(id);
|
|
4364
4959
|
},
|
|
4365
4960
|
{ api, cache: new Map() },
|
|
@@ -4508,7 +5103,7 @@ When several places set the same field, the strongest one wins:
|
|
|
4508
5103
|
| Strength | Where it is set |
|
|
4509
5104
|
| -------- | --------------- |
|
|
4510
5105
|
| 1 (strongest) | the second argument of `emit` |
|
|
4511
|
-
| 2 | the scope of the action
|
|
5106
|
+
| 2 | the scope of the action or job |
|
|
4512
5107
|
| 3 | the scope of the module that owns the handler |
|
|
4513
5108
|
| 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
|
|
4514
5109
|
|
|
@@ -4602,6 +5197,23 @@ Use this page when **your domain logic lives in classes**: a service, a store or
|
|
|
4602
5197
|
|
|
4603
5198
|
If your logic is plain functions, stay with [modules](actions.md#mount-domains-with-modules): a module is the same idea without a class.
|
|
4604
5199
|
|
|
5200
|
+
## On this page
|
|
5201
|
+
|
|
5202
|
+
- [The problem without it](#the-problem-without-it)
|
|
5203
|
+
- [Make an independent emitter with `Emitter`](#make-an-independent-emitter-with-emitter)
|
|
5204
|
+
- [Type the events (optional)](#type-the-events-optional)
|
|
5205
|
+
- [Give a class the methods of a shared bus](#give-a-class-the-methods-of-a-shared-bus)
|
|
5206
|
+
- [Extend the result with no class of your own](#extend-the-result-with-no-class-of-your-own)
|
|
5207
|
+
- [Let the class make its bus](#let-the-class-make-its-bus)
|
|
5208
|
+
- [End the subscriptions with `dispose`](#end-the-subscriptions-with-dispose)
|
|
5209
|
+
- [Stream an event with a `Subject`](#stream-an-event-with-a-subject)
|
|
5210
|
+
- [Keep a method of your own](#keep-a-method-of-your-own)
|
|
5211
|
+
- [Subscribe a method with `@On` and `@Once`](#subscribe-a-method-with-on-and-once)
|
|
5212
|
+
- [Decorate the class with `@WithBus`](#decorate-the-class-with-withbus)
|
|
5213
|
+
- [Pass a method by hand](#pass-a-method-by-hand)
|
|
5214
|
+
- [Type the instances (optional)](#type-the-instances-optional)
|
|
5215
|
+
- [What is not done](#what-is-not-done)
|
|
5216
|
+
|
|
4605
5217
|
## The problem without it
|
|
4606
5218
|
|
|
4607
5219
|
A class that listens to the bus keeps a reference to it, and keeps every unsubscribe function in a list to call when the object goes away. Forget one and the old object keeps handling events. `extendBus` does both for you.
|
|
@@ -4903,7 +5515,7 @@ export const SearchService = extendBus(bus, SearchServiceBase);
|
|
|
4903
5515
|
|
|
4904
5516
|
Things to know:
|
|
4905
5517
|
|
|
4906
|
-
- The event is a name
|
|
5518
|
+
- The event is a name or an action creator, as for `bus.on`. For a creator the payload of the method is checked against it.
|
|
4907
5519
|
- The second argument, `wrap`, takes the bound handler and returns the one to subscribe: `latest`, or `(handler) => debounce(300, handler)`. A policy needs it: `latest(this.search)` wraps the function without `this`, and the bound handler is what `wrap` gets.
|
|
4908
5520
|
- The subscriptions are made right after the constructor of `Base`. An event that is emitted inside that constructor does not reach them.
|
|
4909
5521
|
- A subclass inherits them. A method that a subclass overrides is subscribed only if the override is decorated.
|
|
@@ -5032,7 +5644,7 @@ export const getUser = createAction(
|
|
|
5032
5644
|
},
|
|
5033
5645
|
);
|
|
5034
5646
|
|
|
5035
|
-
// createModule: m.use binds the handler the
|
|
5647
|
+
// createModule: m.use binds the handler the action carries, while the module is mounted
|
|
5036
5648
|
export const userModule = createModule("user", (m) => {
|
|
5037
5649
|
m.use(getUser);
|
|
5038
5650
|
});
|
|
@@ -5282,6 +5894,17 @@ Source: docs/migrating-from-effector.md
|
|
|
5282
5894
|
|
|
5283
5895
|
Read this page if your project uses Effector and you want to know where nanoeffects fits. They overlap in one place: events that start effects. Effector also holds state; nanoeffects does not. It maps the names, shows one effect both ways, and lists what has no equivalent.
|
|
5284
5896
|
|
|
5897
|
+
## On this page
|
|
5898
|
+
|
|
5899
|
+
- [Split the work](#split-the-work)
|
|
5900
|
+
- [Find the equivalent](#find-the-equivalent)
|
|
5901
|
+
- [Rewrite an effect](#rewrite-an-effect)
|
|
5902
|
+
- [Keep the state in Zustand](#keep-the-state-in-zustand)
|
|
5903
|
+
- [Rewrite a form](#rewrite-a-form)
|
|
5904
|
+
- [Replace the log with a request](#replace-the-log-with-a-request)
|
|
5905
|
+
- [Use both in one app](#use-both-in-one-app)
|
|
5906
|
+
- [What is different](#what-is-different)
|
|
5907
|
+
|
|
5285
5908
|
## Split the work
|
|
5286
5909
|
|
|
5287
5910
|
| Job | Use |
|
|
@@ -5912,6 +6535,16 @@ Source: docs/semantics.md
|
|
|
5912
6535
|
|
|
5913
6536
|
Guarantees and sharp edges. Read when something surprises you; not required to start.
|
|
5914
6537
|
|
|
6538
|
+
## Five rules to know first
|
|
6539
|
+
|
|
6540
|
+
1. A handler is `(payload, ctx)`. Use `ctx.signal`, `ctx.emit` and `ctx.on`; the rest of `ctx` is optional.
|
|
6541
|
+
2. Pass `ctx.signal` to what the handler calls (`fetch`, a client), so an abort stops the work.
|
|
6542
|
+
3. An aborted task delivers no result and emits nothing.
|
|
6543
|
+
4. `emit` is synchronous: the first handler error is thrown, unless `onError` is set.
|
|
6544
|
+
5. Rejections of async handlers are not handled without `onError`.
|
|
6545
|
+
|
|
6546
|
+
Details of each rule and the rest:
|
|
6547
|
+
|
|
5915
6548
|
- **An aborted task delivers no result.** If the task aborts before a promise settles, the promise rejects with the abort reason, even if the value was just produced in the same tick. `latest`, `debounce`, `all`, `race`, `take`, and the rest follow this rule.
|
|
5916
6549
|
- **`emit` is a plain, synchronous loop** (see [Errors](errors.md)). A `ctx.emit` inside a handler is the same call: without `onError`, an error of a nested handler is thrown into the handler that emitted; with `onError` it goes there and `ctx.emit` does not throw.
|
|
5917
6550
|
- **Rejections of async handlers** are not handled for you. Without `onError` they are unhandled rejections, which end a Node process by default. Use `onError` for async handlers.
|
|
@@ -5927,6 +6560,22 @@ Find out what your app did and why: which events were emitted, which calls a pol
|
|
|
5927
6560
|
|
|
5928
6561
|
Tracing is optional. A bus works the same without it, and it costs almost nothing until you turn it on. If you only want to see what happens while you develop, jump to [Show every emit](#show-every-emit) and [Configure Redux DevTools](#configure-redux-devtools); the rest of the page is for apps that need to follow a flow or turn tracing off in a build.
|
|
5929
6562
|
|
|
6563
|
+
## On this page
|
|
6564
|
+
|
|
6565
|
+
- [Attach a tracer](#attach-a-tracer)
|
|
6566
|
+
- [Show every emit](#show-every-emit)
|
|
6567
|
+
- [Show which subscription began a chain](#show-which-subscription-began-a-chain)
|
|
6568
|
+
- [Follow one flow with a traceGroupId](#follow-one-flow-with-a-tracegroupid)
|
|
6569
|
+
- [Name a listener](#name-a-listener)
|
|
6570
|
+
- [Keep the chain: `ctx.emit`, not `bus.emit`](#keep-the-chain-ctxemit-not-busemit)
|
|
6571
|
+
- [Turn the names off](#turn-the-names-off)
|
|
6572
|
+
- [Use built-in loggers](#use-built-in-loggers)
|
|
6573
|
+
- [Cut volume in production](#cut-volume-in-production)
|
|
6574
|
+
- [Keep the last events for an incident](#keep-the-last-events-for-an-incident)
|
|
6575
|
+
- [Configure Redux DevTools](#configure-redux-devtools)
|
|
6576
|
+
- [Write your own logger](#write-your-own-logger)
|
|
6577
|
+
- [Drop tracing from a build](#drop-tracing-from-a-build)
|
|
6578
|
+
|
|
5930
6579
|
## Attach a tracer
|
|
5931
6580
|
|
|
5932
6581
|
Pass `trace` to `createBus`. Make it with `createTracer` from `nanoeffects/trace`, which takes a logger (a function) or an array of them. The tracing code lives in that entry, so a bus without `trace` does not carry it.
|
|
@@ -6009,7 +6658,7 @@ The event changes nothing else: the other tasks keep their `parentId`, `rootId`
|
|
|
6009
6658
|
|
|
6010
6659
|
## Show which subscription began a chain
|
|
6011
6660
|
|
|
6012
|
-
**Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of
|
|
6661
|
+
**Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of actions and retries.
|
|
6013
6662
|
|
|
6014
6663
|
**When.** The bus has a tracer and the project has several `useEmitter` or `bus.emitter` calls. The names come from `tracePlugin`: use it on the bus, or the `traceId` is ignored.
|
|
6015
6664
|
|
|
@@ -6037,8 +6686,8 @@ In a component, the same option goes in the options of the hook: `useEmitter(nam
|
|
|
6037
6686
|
|
|
6038
6687
|
```
|
|
6039
6688
|
[nanoeffects] Profile · run start
|
|
6040
|
-
[nanoeffects] user/save ·
|
|
6041
|
-
[nanoeffects] user/save ·
|
|
6689
|
+
[nanoeffects] user/save · action start
|
|
6690
|
+
[nanoeffects] user/save · action done
|
|
6042
6691
|
[nanoeffects] Profile · run done
|
|
6043
6692
|
```
|
|
6044
6693
|
|
|
@@ -6055,9 +6704,9 @@ Every event of a task below another one has `rootId`, the id of the root of the
|
|
|
6055
6704
|
|
|
6056
6705
|
**Problem.** A flow such as a checkout has several roots (a click, a timer, a store) and many branches. Parent ids give a tree, but you want to filter the log by the flow.
|
|
6057
6706
|
|
|
6058
|
-
**When.** One business flow runs through several
|
|
6707
|
+
**When.** One business flow runs through several actions, and you read the log by flow.
|
|
6059
6708
|
|
|
6060
|
-
Give the flow a `traceGroupId` in the scope of the
|
|
6709
|
+
Give the flow a `traceGroupId` in the scope of the action or the job that starts it. Every task that it starts through `ctx.emit` or `ctx.run` has the same `traceGroupId`, and so does every trace event of those tasks. A task that declares its own `traceGroupId` uses it for itself and for what it starts.
|
|
6061
6710
|
|
|
6062
6711
|
```js
|
|
6063
6712
|
import { createBus } from "nanoeffects";
|
|
@@ -6078,7 +6727,7 @@ const pay = createAction("checkout/pay", async () => {}, {
|
|
|
6078
6727
|
```
|
|
6079
6728
|
|
|
6080
6729
|
```
|
|
6081
|
-
[nanoeffects] [checkout] checkout/pay ·
|
|
6730
|
+
[nanoeffects] [checkout] checkout/pay · action start
|
|
6082
6731
|
```
|
|
6083
6732
|
|
|
6084
6733
|
The emitters take it too: `useEmitter(cartChanged, cart, { traceId: "Cart", traceGroupId: "checkout" })`. `traceGroupId` works without `scopePlugin`, and needs `tracePlugin`.
|
|
@@ -6108,7 +6757,7 @@ bus.once("name/changed", (name) => console.log(name), { traceId: "auditLog" });
|
|
|
6108
6757
|
|
|
6109
6758
|
```
|
|
6110
6759
|
[nanoeffects] saveNotifier · run start
|
|
6111
|
-
[nanoeffects] user/save ·
|
|
6760
|
+
[nanoeffects] user/save · action start
|
|
6112
6761
|
[nanoeffects] saveNotifier · run done
|
|
6113
6762
|
```
|
|
6114
6763
|
|
|
@@ -6125,7 +6774,7 @@ The names work wherever an event or a listener is made:
|
|
|
6125
6774
|
| A listener, `bus.on` and `bus.once` | `{ traceId }` as the third argument |
|
|
6126
6775
|
| A listener of a module or of an instance of `extendBus` | the same third argument of its `on` and `once` |
|
|
6127
6776
|
| A method with `@On` or `@Once` | `{ traceId }` in the decorator |
|
|
6128
|
-
|
|
|
6777
|
+
| An action or a job | `traceGroupId` in its scope |
|
|
6129
6778
|
|
|
6130
6779
|
## Turn the names off
|
|
6131
6780
|
|
|
@@ -6153,7 +6802,7 @@ const bus = createBus({
|
|
|
6153
6802
|
| `reduxDevtools()` | Sends the emits to Redux DevTools, and with `policies` the stages of the handlers as `type/effect/phase`; state is `{ running }` |
|
|
6154
6803
|
| `consoleLogger()` | Prints one line per phase, indented by task depth |
|
|
6155
6804
|
| `performanceLogger()` | User Timing measures for the browser Performance panel |
|
|
6156
|
-
| `createTraceRecorder()` | Keeps events in `recorder.events
|
|
6805
|
+
| `createTraceRecorder()` | Keeps events in `recorder.events`: all of them for tests, or with `{ capacity }` the last N ([see below](#keep-the-last-events-for-an-incident)) |
|
|
6157
6806
|
|
|
6158
6807
|
### Cut volume in production
|
|
6159
6808
|
|
|
@@ -6169,8 +6818,60 @@ createTracer(logger, {
|
|
|
6169
6818
|
- `sample` is decided once per root task; every task below it follows, so a trace is never cut in the middle. Unsampled tasks cost almost nothing.
|
|
6170
6819
|
- `filter` runs on each event before any logger sees it.
|
|
6171
6820
|
|
|
6821
|
+
### Keep the last events for an incident
|
|
6822
|
+
|
|
6823
|
+
`createTraceRecorder()` with no option keeps every event, so a long session fills memory. That is fine in a test and wrong in a running app. Give it a `capacity` and it keeps only the last N events (a ring buffer): when something goes wrong, you still have what led up to it.
|
|
6824
|
+
|
|
6825
|
+
<!-- docs-check: new -->
|
|
6826
|
+
|
|
6827
|
+
```js
|
|
6828
|
+
// bus.js
|
|
6829
|
+
import { createBus } from "nanoeffects";
|
|
6830
|
+
import { createTraceRecorder, createTracer } from "nanoeffects/trace";
|
|
6831
|
+
|
|
6832
|
+
// A logger that keeps the last 500 events and drops the older ones.
|
|
6833
|
+
export const recorder = createTraceRecorder({ capacity: 500 });
|
|
6834
|
+
|
|
6835
|
+
// Feed the recorder from the tracer.
|
|
6836
|
+
const trace = createTracer(recorder);
|
|
6837
|
+
|
|
6838
|
+
export const bus = createBus({ trace });
|
|
6839
|
+
```
|
|
6840
|
+
|
|
6841
|
+
```js
|
|
6842
|
+
// incident.js
|
|
6843
|
+
import { recorder } from "./bus";
|
|
6844
|
+
|
|
6845
|
+
// Keep what is safe to send. An event also has `payload` and `error`.
|
|
6846
|
+
const withoutPayload = ({ id, parentId, type, effect, phase }) => ({
|
|
6847
|
+
id,
|
|
6848
|
+
parentId,
|
|
6849
|
+
type,
|
|
6850
|
+
effect,
|
|
6851
|
+
phase,
|
|
6852
|
+
});
|
|
6853
|
+
|
|
6854
|
+
window.addEventListener("error", () => {
|
|
6855
|
+
const events = recorder.events; // a snapshot, oldest event first
|
|
6856
|
+
const safe = events.map(withoutPayload);
|
|
6857
|
+
const body = JSON.stringify(safe);
|
|
6858
|
+
navigator.sendBeacon("/api/incident", body);
|
|
6859
|
+
});
|
|
6860
|
+
```
|
|
6861
|
+
|
|
6862
|
+
What to know before you send the events:
|
|
6863
|
+
|
|
6864
|
+
- **`payload` and `error` can hold private data**: what a user typed, tokens, personal text. The recorder keeps them as they are and does not redact. Choose what to send, as `withoutPayload` does.
|
|
6865
|
+
- **There is no time on an event.** You get the order, not the duration. To add a time, wrap the recorder in a logger that stamps `performance.now()` on a copy of the event.
|
|
6866
|
+
- **An old `parentId` can point at an event that was pushed out.** When you build a tree from the events, treat an unknown `parentId` as a root.
|
|
6867
|
+
- **`recorder.events` is a snapshot with a `capacity`.** Each read builds a new array, so read it once at the moment you need it. Without a `capacity` it is one live array.
|
|
6868
|
+
- **A low `sample` thins the buffer.** `createTracer(recorder, { sample: 0.05 })` records 5% of root tasks, so the buffer may hold little when the incident comes. See [Cut volume in production](#cut-volume-in-production).
|
|
6869
|
+
- **A `capacity` that is not a positive integer** (`0`, a negative number, `NaN`) means no limit.
|
|
6870
|
+
|
|
6172
6871
|
### Configure Redux DevTools
|
|
6173
6872
|
|
|
6873
|
+
<!-- docs-check: new -->
|
|
6874
|
+
|
|
6174
6875
|
Read-only (the bus owns no state, so there is no time travel). Does nothing when the extension is not installed. Has its own instance in the DevTools picker, apart from your store.
|
|
6175
6876
|
|
|
6176
6877
|
Quiet by default, so you see what happened before you see how:
|
|
@@ -6270,6 +6971,8 @@ Source: docs/observable.md
|
|
|
6270
6971
|
|
|
6271
6972
|
Use this page when your app already works with Observables or RxJS and you want a bus event as a stream. If you start from scratch, prefer `bus.on` with policies: they cancel work and show up in traces.
|
|
6272
6973
|
|
|
6974
|
+
The same entry has `events`, which needs no stream library: it gives an event to a `for await` loop. See [Read an event with a `for await` loop](#read-an-event-with-a-for-await-loop).
|
|
6975
|
+
|
|
6273
6976
|
`createObserve(bus)` from `nanoeffects/observable` turns a bus event into an Observable that follows the TC39 proposal (`subscribe`, `unsubscribe`, `Symbol.observable`). RxJS and other stream libraries take it as it is. Nothing here depends on them.
|
|
6274
6977
|
|
|
6275
6978
|
## Turn an event into a stream
|
|
@@ -6413,6 +7116,47 @@ bus.on(
|
|
|
6413
7116
|
|
|
6414
7117
|
`switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
|
|
6415
7118
|
|
|
7119
|
+
## Read an event with a `for await` loop
|
|
7120
|
+
|
|
7121
|
+
Problem: a task wants to handle the payloads of one event one after another, in order, and stop when the screen closes. `take` waits for one payload, and a listener has no place for a loop.
|
|
7122
|
+
|
|
7123
|
+
`events(source, event, options)` from `nanoeffects/observable` returns an async iterator of the payloads. It listens from the call, so a payload that comes before the first read is kept. `source` is a bus or a task context; with a context the loop also ends when the task aborts.
|
|
7124
|
+
|
|
7125
|
+
```js
|
|
7126
|
+
import { createBus } from "nanoeffects";
|
|
7127
|
+
import { events } from "nanoeffects/observable";
|
|
7128
|
+
|
|
7129
|
+
const bus = createBus();
|
|
7130
|
+
const controller = new AbortController();
|
|
7131
|
+
|
|
7132
|
+
// events — the "query/changed" payloads one by one; keeps the last 3 while the loop is busy
|
|
7133
|
+
const queries = events(bus, "query/changed", {
|
|
7134
|
+
signal: controller.signal,
|
|
7135
|
+
buffer: 3,
|
|
7136
|
+
});
|
|
7137
|
+
|
|
7138
|
+
async function watchQueries() {
|
|
7139
|
+
for await (const query of queries) {
|
|
7140
|
+
console.log("search for", query); // runs once per payload, in order
|
|
7141
|
+
}
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7144
|
+
watchQueries().catch((reason) => console.log("stopped:", reason)); // the abort rejects the loop
|
|
7145
|
+
bus.emit("query/changed", "cat");
|
|
7146
|
+
controller.abort("screen closed"); // the loop ends with this reason
|
|
7147
|
+
```
|
|
7148
|
+
|
|
7149
|
+
| Option | What it does | Default |
|
|
7150
|
+
| ------ | ------------ | ------- |
|
|
7151
|
+
| `signal` | Ends the loop. The waiting read rejects with the abort reason. | none |
|
|
7152
|
+
| `buffer` | The most payloads kept while the loop is busy. | `Infinity` |
|
|
7153
|
+
| `overflow` | Which payload goes when the buffer is full: `"drop-oldest"` or `"drop-newest"`. | `"drop-oldest"` |
|
|
7154
|
+
|
|
7155
|
+
- `break`, `return` and a throw inside the loop remove the listener.
|
|
7156
|
+
- With no `buffer`, a loop that never reads keeps every payload. Set `buffer` for a slow loop.
|
|
7157
|
+
- An event of a bus does not end, so the loop ends only by `break`, the `signal` or the task.
|
|
7158
|
+
- Inside a task, `events(ctx, "query/changed")` needs no `signal`: the task ending is the end.
|
|
7159
|
+
|
|
6416
7160
|
## Convert back to a stream or a promise
|
|
6417
7161
|
|
|
6418
7162
|
| You need | Write |
|