nanoeffects 0.28.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.
Files changed (76) hide show
  1. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-jeEqIKgK.d.ts} +34 -32
  2. package/dist/actions.d.ts +18 -18
  3. package/dist/actions.js +1 -1
  4. package/dist/bus-0uYn_kRj.js +1 -0
  5. package/dist/context-DtUnzC-1.js +1 -0
  6. package/dist/context-types-Bme1mzkn.d.ts +31 -0
  7. package/dist/context.d.ts +8 -3
  8. package/dist/context.js +1 -1
  9. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  10. package/dist/dev.d.ts +2 -2
  11. package/dist/effects.d.ts +2 -2
  12. package/dist/effects.js +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/match.d.ts +3 -3
  16. package/dist/match.js +1 -1
  17. package/dist/{module-C_oEXw1n.d.ts → module-BWMW83qk.d.ts} +38 -38
  18. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-xpQ_I2h8.d.ts} +34 -32
  19. package/dist/notrace/actions.d.ts +52 -52
  20. package/dist/notrace/actions.js +1 -1
  21. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  22. package/dist/notrace/effects.d.ts +2 -2
  23. package/dist/notrace/index.d.ts +2 -2
  24. package/dist/notrace/index.js +1 -1
  25. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-6LhNx0th.d.ts} +1 -1
  26. package/dist/notrace/policies.d.ts +1 -1
  27. package/dist/notrace/redux.d.ts +1 -1
  28. package/dist/observable.d.ts +43 -1
  29. package/dist/observable.js +1 -1
  30. package/dist/onAbort-BgMfspK4.js +1 -0
  31. package/dist/oop.d.ts +3 -3
  32. package/dist/oop.js +1 -1
  33. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BaErevLy.d.ts} +2 -2
  34. package/dist/policies.d.ts +2 -2
  35. package/dist/policies.js +1 -1
  36. package/dist/react.d.ts +2 -2
  37. package/dist/react.js +1 -1
  38. package/dist/redux.d.ts +1 -1
  39. package/dist/scope.d.ts +9 -9
  40. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  41. package/dist/signal.d.ts +34 -6
  42. package/dist/signal.js +1 -1
  43. package/dist/svelte.d.ts +1 -1
  44. package/dist/trace.d.ts +20 -5
  45. package/dist/trace.js +1 -1
  46. package/dist/{types-FV8fuvI9.d.ts → types-D5xsR37j.d.ts} +2 -2
  47. package/dist/vue.d.ts +1 -1
  48. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-BZVRBUOm.d.ts} +1 -1
  49. package/dist/{with-policy-D-SQRkPh.js → with-policy-C6BIiPIi.js} +1 -1
  50. package/docs/README.md +13 -1
  51. package/docs/actions.md +27 -1
  52. package/docs/chat-example.md +12 -0
  53. package/docs/chat-widget.md +7 -7
  54. package/docs/context.md +33 -1
  55. package/docs/effects.md +20 -9
  56. package/docs/guide.md +20 -2
  57. package/docs/migrating-from-effector.md +11 -0
  58. package/docs/observable.md +43 -0
  59. package/docs/oop.md +17 -0
  60. package/docs/package.md +22 -13
  61. package/docs/plugins.md +69 -11
  62. package/docs/query-libraries.md +273 -5
  63. package/docs/react.md +12 -2
  64. package/docs/scope.md +14 -2
  65. package/docs/semantics.md +10 -0
  66. package/docs/server.md +3 -3
  67. package/docs/stability.md +2 -2
  68. package/docs/tracing.md +74 -6
  69. package/docs/typescript.md +1 -1
  70. package/llms-full.txt +671 -66
  71. package/llms.txt +7 -7
  72. package/package.json +4 -4
  73. package/skills/nanoeffects/SKILL.md +2 -2
  74. package/dist/bus-DlpEluzd.js +0 -1
  75. package/dist/context-UcmrAIhP.js +0 -1
  76. package/dist/context-types-hCKgU8sC.d.ts +0 -14
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(command(payload))` runs the command's handler and returns its result. Plain events return nothing.
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.
@@ -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: ({ signal }) => api.user(id, { signal }),
295
- posts: ({ signal }) => api.posts(id, { signal }),
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
  });
@@ -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
- | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` |
705
- | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events |
706
- | `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value |
707
- | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` |
708
- | `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()` |
709
- | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) |
710
- | `nanoeffects/redux` | `createEffectsMiddleware` |
711
- | `nanoeffects/observable` | `createObserve`, `Subject` |
712
- | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
713
- | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) |
714
- | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
715
- | `nanoeffects/dev` | `devPlugin` (no-op in production) |
716
- | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
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 gets the signal
808
- const user = await retry(ctx, ({ signal }) => fetchUser(id, { signal }), { times: 3 });
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: ({ signal }) => api.get(id, { signal }),
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 commands and tasks are gone
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 a command work on this bus
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 command only
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 SWR](query-libraries.md).
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 a command, a job or an action
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 command
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 commands, actions, jobs, modules and emit
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, ({ signal }) => {
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
- data: ({ signal }) => fetchData(id, { signal }),
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: ({ signal }) => fetchUser(id, { signal }),
1868
- posts: ({ signal }) => fetchPosts(id, { signal }),
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: ({ signal }) => fetchUser(id, { signal }),
1992
- posts: ({ signal }) => fetchPosts(id, { signal }),
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: `({ signal }) => fetchUser(id, { signal })`. The same holds for `all`, `allSettled` and `timeout`.
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 command "..."` and nothing is sent. An action without a handler is an event and does not throw.
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
 
@@ -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 SWR
3323
+ # Use nanoeffects with React Query, SWR and RTK Query
3324
+
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.
3202
3326
 
3203
- Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query) or [SWR](https://swr.vercel.app) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one action that both libraries call.
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
3204
3330
 
3205
- You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
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 command; { signal } ties its task to the signal of React Query
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.** The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
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 — a command with a handler: emit returns what the handler returns
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 command: timeout, retry and the request
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 — a command with a handler
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 SWR](query-libraries.md).
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:
@@ -4105,7 +4517,7 @@ It is a level D example: the logic lives in one module, and the page only mounts
4105
4517
  | `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
4106
4518
  | `src/chat-module.ts` | The list of what is alive while the chat is open. |
4107
4519
  | `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
4108
- | `src/main.ts` | The launcher: the only file that knows both the module and the panel. |
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. |
4109
4521
 
4110
4522
  ## Bind everything in one module
4111
4523
 
@@ -4155,7 +4567,7 @@ Three details that make the cleanup complete:
4155
4567
 
4156
4568
  ## Open and close from the launcher
4157
4569
 
4158
- `app.mount` returns the function that unmounts the module. The launcher puts it together with the one that removes the panel.
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`.
4159
4571
 
4160
4572
  <!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
4161
4573
  ```js
@@ -4171,7 +4583,10 @@ let close = null; // set while the chat is open
4171
4583
  const open = () => {
4172
4584
  const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
4173
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?.());
4174
4588
  close = () => {
4589
+ stopEscape();
4175
4590
  stopPanel();
4176
4591
  stopModule(); // everything the module started ends, a running request included
4177
4592
  close = null;
@@ -4185,18 +4600,15 @@ button.addEventListener("click", () => {
4185
4600
  open();
4186
4601
  }
4187
4602
  });
4188
-
4189
- // the launcher stays subscribed: it must hear Escape to close the chat
4190
- bus.on(closeRequested, () => close?.());
4191
4603
  ```
4192
4604
 
4193
- The launcher's own `bus.on` is the only subscription that lives while the chat is closed, and it does nothing then: `close` is `null`.
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.
4194
4606
 
4195
4607
  ## Check that closing cleans up
4196
4608
 
4197
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.
4198
4610
  2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
4199
- 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: only `chat/closeRequested` has a listener (the launcher). Open: `chat/sent`, `chat/messageAdded` and `chat/typing` have one each as well. That is the proof that nothing is left subscribed.
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.
4200
4612
  4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
4201
4613
  5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
4202
4614
  6. Open the chat again. The old lines are back, and the polling runs.
@@ -4420,7 +4832,7 @@ Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may
4420
4832
 
4421
4833
  To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
4422
4834
 
4423
- To give fields to **one** command, action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
4835
+ To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
4424
4836
 
4425
4837
  ## Type the fields (optional)
4426
4838
 
@@ -4444,6 +4856,38 @@ bus.on("save", (draft, { store }) => store.put(draft));
4444
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.
4445
4857
  - The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
4446
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
+
4447
4891
  ### Name the fields in the event map instead
4448
4892
 
4449
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.
@@ -4468,6 +4912,18 @@ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`,
4468
4912
 
4469
4913
  You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
4470
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
+
4471
4927
  ## Turn scopes on
4472
4928
 
4473
4929
  Scopes are a plugin, so a bus that does not use them pays nothing.
@@ -4498,7 +4954,7 @@ import { createAction } from "nanoeffects/actions";
4498
4954
  export const getUser = createAction(
4499
4955
  "user/get",
4500
4956
  (id, { api, cache }) => {
4501
- // api and cache are on ctx for this command only
4957
+ // api and cache are on ctx for this action only
4502
4958
  return cache.get(id) ?? api.getUser(id);
4503
4959
  },
4504
4960
  { api, cache: new Map() },
@@ -4647,7 +5103,7 @@ When several places set the same field, the strongest one wins:
4647
5103
  | Strength | Where it is set |
4648
5104
  | -------- | --------------- |
4649
5105
  | 1 (strongest) | the second argument of `emit` |
4650
- | 2 | the scope of the action, command or job |
5106
+ | 2 | the scope of the action or job |
4651
5107
  | 3 | the scope of the module that owns the handler |
4652
5108
  | 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
4653
5109
 
@@ -4741,6 +5197,23 @@ Use this page when **your domain logic lives in classes**: a service, a store or
4741
5197
 
4742
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.
4743
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
+
4744
5217
  ## The problem without it
4745
5218
 
4746
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.
@@ -5171,7 +5644,7 @@ export const getUser = createAction(
5171
5644
  },
5172
5645
  );
5173
5646
 
5174
- // createModule: m.use binds the handler the command carries, while the module is mounted
5647
+ // createModule: m.use binds the handler the action carries, while the module is mounted
5175
5648
  export const userModule = createModule("user", (m) => {
5176
5649
  m.use(getUser);
5177
5650
  });
@@ -5421,6 +5894,17 @@ Source: docs/migrating-from-effector.md
5421
5894
 
5422
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.
5423
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
+
5424
5908
  ## Split the work
5425
5909
 
5426
5910
  | Job | Use |
@@ -6051,6 +6535,16 @@ Source: docs/semantics.md
6051
6535
 
6052
6536
  Guarantees and sharp edges. Read when something surprises you; not required to start.
6053
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
+
6054
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.
6055
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.
6056
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.
@@ -6066,6 +6560,22 @@ Find out what your app did and why: which events were emitted, which calls a pol
6066
6560
 
6067
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.
6068
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
+
6069
6579
  ## Attach a tracer
6070
6580
 
6071
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.
@@ -6148,7 +6658,7 @@ The event changes nothing else: the other tasks keep their `parentId`, `rootId`
6148
6658
 
6149
6659
  ## Show which subscription began a chain
6150
6660
 
6151
- **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 commands and retries.
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.
6152
6662
 
6153
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.
6154
6664
 
@@ -6176,8 +6686,8 @@ In a component, the same option goes in the options of the hook: `useEmitter(nam
6176
6686
 
6177
6687
  ```
6178
6688
  [nanoeffects] Profile · run start
6179
- [nanoeffects] user/save · command start
6180
- [nanoeffects] user/save · command done
6689
+ [nanoeffects] user/save · action start
6690
+ [nanoeffects] user/save · action done
6181
6691
  [nanoeffects] Profile · run done
6182
6692
  ```
6183
6693
 
@@ -6217,7 +6727,7 @@ const pay = createAction("checkout/pay", async () => {}, {
6217
6727
  ```
6218
6728
 
6219
6729
  ```
6220
- [nanoeffects] [checkout] checkout/pay · command start
6730
+ [nanoeffects] [checkout] checkout/pay · action start
6221
6731
  ```
6222
6732
 
6223
6733
  The emitters take it too: `useEmitter(cartChanged, cart, { traceId: "Cart", traceGroupId: "checkout" })`. `traceGroupId` works without `scopePlugin`, and needs `tracePlugin`.
@@ -6247,7 +6757,7 @@ bus.once("name/changed", (name) => console.log(name), { traceId: "auditLog" });
6247
6757
 
6248
6758
  ```
6249
6759
  [nanoeffects] saveNotifier · run start
6250
- [nanoeffects] user/save · command start
6760
+ [nanoeffects] user/save · action start
6251
6761
  [nanoeffects] saveNotifier · run done
6252
6762
  ```
6253
6763
 
@@ -6292,7 +6802,7 @@ const bus = createBus({
6292
6802
  | `reduxDevtools()` | Sends the emits to Redux DevTools, and with `policies` the stages of the handlers as `type/effect/phase`; state is `{ running }` |
6293
6803
  | `consoleLogger()` | Prints one line per phase, indented by task depth |
6294
6804
  | `performanceLogger()` | User Timing measures for the browser Performance panel |
6295
- | `createTraceRecorder()` | Keeps events in `recorder.events`, for tests |
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)) |
6296
6806
 
6297
6807
  ### Cut volume in production
6298
6808
 
@@ -6308,8 +6818,60 @@ createTracer(logger, {
6308
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.
6309
6819
  - `filter` runs on each event before any logger sees it.
6310
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
+
6311
6871
  ### Configure Redux DevTools
6312
6872
 
6873
+ <!-- docs-check: new -->
6874
+
6313
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.
6314
6876
 
6315
6877
  Quiet by default, so you see what happened before you see how:
@@ -6409,6 +6971,8 @@ Source: docs/observable.md
6409
6971
 
6410
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.
6411
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
+
6412
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.
6413
6977
 
6414
6978
  ## Turn an event into a stream
@@ -6552,6 +7116,47 @@ bus.on(
6552
7116
 
6553
7117
  `switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
6554
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
+
6555
7160
  ## Convert back to a stream or a promise
6556
7161
 
6557
7162
  | You need | Write |