nanoeffects 0.29.0 → 0.31.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 (127) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
  3. package/dist/actions.d.ts +3 -3
  4. package/dist/actions.js +1 -1
  5. package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
  8. package/dist/context.d.ts +2 -2
  9. package/dist/dev.d.ts +49 -3
  10. package/dist/dev.js +2 -1
  11. package/dist/effects.d.ts +2 -2
  12. package/dist/effects.js +1 -1
  13. package/dist/emit-key-C6FL7Ewn.js +1 -0
  14. package/dist/guard.d.ts +59 -0
  15. package/dist/guard.js +1 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/match.d.ts +1 -1
  19. package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
  20. package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
  21. package/dist/notrace/actions.d.ts +2 -2
  22. package/dist/notrace/effects.d.ts +2 -2
  23. package/dist/notrace/index.d.ts +1 -1
  24. package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  25. package/dist/notrace/policies.d.ts +1 -1
  26. package/dist/notrace/redux.d.ts +1 -1
  27. package/dist/observable.d.ts +1 -1
  28. package/dist/oop.d.ts +1 -1
  29. package/dist/oop.js +1 -1
  30. package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  31. package/dist/policies.d.ts +2 -2
  32. package/dist/policies.js +1 -1
  33. package/dist/production/dev.js +1 -1
  34. package/dist/react.d.ts +2 -2
  35. package/dist/react.js +1 -1
  36. package/dist/redux.d.ts +1 -1
  37. package/dist/scope.d.ts +2 -2
  38. package/dist/signal.d.ts +22 -2
  39. package/dist/signal.js +1 -1
  40. package/dist/site-DLvUoXs3.js +1 -0
  41. package/dist/svelte.d.ts +1 -1
  42. package/dist/trace.d.ts +151 -2
  43. package/dist/trace.js +3 -1
  44. package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
  45. package/dist/vue.d.ts +1 -1
  46. package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
  47. package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  48. package/docs/README.md +62 -31
  49. package/docs/actions.md +44 -27
  50. package/docs/cancel-stale-requests.md +2 -0
  51. package/docs/chat-example.md +3 -1
  52. package/docs/chat-widget.md +2 -0
  53. package/docs/context.md +322 -9
  54. package/docs/debounce-and-abort.md +3 -1
  55. package/docs/debugging-loops.md +318 -0
  56. package/docs/effects.md +255 -138
  57. package/docs/errors.md +4 -2
  58. package/docs/examples.md +60 -0
  59. package/docs/guide.md +39 -22
  60. package/docs/hmr.md +35 -1
  61. package/docs/loop-guard.md +109 -0
  62. package/docs/migrating-from-effector.md +4 -2
  63. package/docs/migrating-from-mitt.md +6 -4
  64. package/docs/migrating-from-rxjs.md +2 -0
  65. package/docs/migrating-from-saga.md +2 -0
  66. package/docs/motivation.md +14 -21
  67. package/docs/observable.md +2 -0
  68. package/docs/oop.md +3 -1
  69. package/docs/package.md +4 -1
  70. package/docs/pass-the-signal.md +346 -0
  71. package/docs/plugins.md +22 -5
  72. package/docs/policies.md +73 -46
  73. package/docs/prevent-double-submit.md +3 -1
  74. package/docs/query-libraries.md +3 -1
  75. package/docs/react.md +42 -13
  76. package/docs/recipes.md +528 -0
  77. package/docs/ru/README.md +62 -0
  78. package/docs/ru/actions.md +412 -0
  79. package/docs/ru/cancel-stale-requests.md +106 -0
  80. package/docs/ru/chat-example.md +259 -0
  81. package/docs/ru/chat-widget.md +131 -0
  82. package/docs/ru/context.md +402 -0
  83. package/docs/ru/debounce-and-abort.md +105 -0
  84. package/docs/ru/debugging-loops.md +318 -0
  85. package/docs/ru/effects.md +538 -0
  86. package/docs/ru/errors.md +36 -0
  87. package/docs/ru/examples.md +60 -0
  88. package/docs/ru/guide.md +530 -0
  89. package/docs/ru/hmr.md +83 -0
  90. package/docs/ru/loop-guard.md +109 -0
  91. package/docs/ru/migrating-from-effector.md +339 -0
  92. package/docs/ru/migrating-from-mitt.md +99 -0
  93. package/docs/ru/migrating-from-rxjs.md +197 -0
  94. package/docs/ru/migrating-from-saga.md +97 -0
  95. package/docs/ru/motivation.md +47 -0
  96. package/docs/ru/observable.md +233 -0
  97. package/docs/ru/oop.md +431 -0
  98. package/docs/ru/package.md +42 -0
  99. package/docs/ru/pass-the-signal.md +346 -0
  100. package/docs/ru/plugins.md +398 -0
  101. package/docs/ru/policies.md +201 -0
  102. package/docs/ru/prevent-double-submit.md +122 -0
  103. package/docs/ru/query-libraries.md +392 -0
  104. package/docs/ru/react.md +309 -0
  105. package/docs/ru/recipes.md +528 -0
  106. package/docs/ru/semantics.md +19 -0
  107. package/docs/ru/server.md +204 -0
  108. package/docs/ru/starter.md +118 -0
  109. package/docs/ru/stores.md +673 -0
  110. package/docs/ru/svelte.md +78 -0
  111. package/docs/ru/tracing.md +466 -0
  112. package/docs/ru/typescript.md +170 -0
  113. package/docs/ru/vue.md +92 -0
  114. package/docs/semantics.md +2 -0
  115. package/docs/server.md +2 -0
  116. package/docs/starter.md +2 -0
  117. package/docs/stores.md +2 -0
  118. package/docs/svelte.md +3 -1
  119. package/docs/tracing.md +58 -1
  120. package/docs/typescript.md +2 -0
  121. package/docs/vue.md +3 -1
  122. package/llms-full.txt +2087 -418
  123. package/llms.txt +11 -8
  124. package/package.json +23 -6
  125. package/dist/context-DtUnzC-1.js +0 -1
  126. package/docs/scope.md +0 -278
  127. package/docs/stability.md +0 -90
@@ -0,0 +1,60 @@
1
+ # Examples
2
+
3
+ > **Levels A to F.** Read first: [README](README.md#the-ladder-from-a-to-f).
4
+
5
+ Examples to run in the browser and to read. Every folder lives in `examples/` of the repository and is type-checked with the project. Two of them are explained step by step: [Build a chat](chat-example.md) and [Build a chat widget](chat-widget.md).
6
+
7
+ ## Try it in the browser
8
+
9
+ All three are the same search box: type fast and only one request starts (`debounce`); type again while a request runs and the older one is aborted (`latest`). StackBlitz runs them with no install.
10
+
11
+ | Folder | What is in it |
12
+ | ------ | ------------- |
13
+ | [`playground`](../examples/playground) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground) | Plain DOM and one file: the smallest setup. The page lists the requests, so the abort is visible. |
14
+ | [`playground-react`](../examples/playground-react) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground-react) | The same search in a React component, with the bus outside React. |
15
+ | [`playground-devtools`](../examples/playground-devtools) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/playground-devtools) | The same search with its emits and stopped calls sent to Redux DevTools. |
16
+
17
+ ## Bigger projects
18
+
19
+ React and TypeScript projects that show where logic goes when an app has several features.
20
+
21
+ | Folder | What is in it |
22
+ | ------ | ------------- |
23
+ | [`chat`](../examples/chat) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat) | Rooms, history under `latest`, ordered sends under `queue` and `retry`, search under `allSettled`, polling with `poll`. Explained in [Build a chat](chat-example.md). |
24
+ | [`pokedex`](../examples/pokedex) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/pokedex) | Real requests to PokeAPI: a search with `debounce`, `latest`, `timeout` and `retry`, abilities with `allSettled`. |
25
+ | [`spells`](../examples/spells) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/spells) | The smallest example: a search in one file, one action that carries its handler. |
26
+ | [`chat-widget`](../examples/chat-widget) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget) | A chat behind a launcher button: opening mounts one module, closing unmounts it. Explained in [Build a chat widget](chat-widget.md). |
27
+ | [`counter`](../examples/counter) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/counter) | The counter of the redux-saga tutorial, without a store and with Redux Toolkit. |
28
+ | [`rickandmorty`](../examples/rickandmorty) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/rickandmorty) | Characters with React Query: requests are actions that get its abort signal. |
29
+ | [`trades`](../examples/trades) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/trades) | A live stream over a WebSocket: `retry`, `latest` and `throttle`. |
30
+
31
+ ## Backend examples
32
+
33
+ Small [Fastify](https://fastify.dev) apps with no database. They need Node 18 or newer: run `npm install` and `npm start` in the folder. More in [Stop work on a server](server.md).
34
+
35
+ | Folder | What is in it |
36
+ | ------ | ------------- |
37
+ | [`api-server`](../examples/api-server) | A server over PokeAPI: `timeout`, `retry`, `allSettled`, and an abort when the client leaves. |
38
+ | [`job-worker`](../examples/job-worker) | A queue worker: `queue`, `retry`, `timeout`, counts from the outcomes of a job. |
39
+ | [`ws-server`](../examples/ws-server) | A WebSocket server: one controller per socket, `latest` with a `key`. |
40
+
41
+ ## Recipes
42
+
43
+ Each file is one job, written as a short program with a comment on every nanoeffects call.
44
+
45
+ | File | Job | Docs |
46
+ | ---- | --- | ---- |
47
+ | [`search.ts`](../examples/search.ts) | A search box that waits for quiet and aborts the older request. | [Policies](policies.md) |
48
+ | [`backoff.ts`](../examples/backoff.ts) | Retry a flaky request with a growing pause. | [Effects](effects.md#backoff) |
49
+ | [`registration-form.ts`](../examples/registration-form.ts) | A sign-up form: a username check that does not race. | [React form](react.md) |
50
+ | [`scope.ts`](../examples/scope.ts) | An action with its own effects and a test that replaces one. | [Working with context](context.md) |
51
+ | [`modules.ts`](../examples/modules.ts) | Domain logic with no UI, mounted and unmounted as one unit. | [Actions](actions.md) |
52
+ | [`redux.ts`](../examples/redux.ts) | Redux Toolkit actions that also reach the bus. | [State managers](stores.md) |
53
+ | [`guide.ts`](../examples/guide.ts) | The code of every step of the guide. | [Guide](guide.md) |
54
+
55
+ ## TypeScript projects
56
+
57
+ | Folder | What is in it |
58
+ | ------ | ------------- |
59
+ | [`typescript`](../examples/typescript) | Name the event map once and use it in modules and actions. |
60
+ | [`typescript-context`](../examples/typescript-context) | Give `ctx` the fields of your bus and type them from one place. |
package/docs/guide.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # Guide
2
2
 
3
- Climb one step at a time: small bus → policies → effects → handlers and modules. Stop when your project has what it needs.
3
+ > **Levels A to D.** Read first: [README](../README.md) (install and the first example).
4
4
 
5
- | Your project | Stop after |
6
- | ------------ | ---------- |
7
- | Widget / prototype | §1–2 |
8
- | SPA with async UI | §3–4 |
9
- | Unreliable network / long flows | §5–6 |
10
- | Domain logic off the UI | §7–9 |
5
+ Climb one step at a time: small bus → policies → effects → handlers and modules. Stop when your project has what it needs. Install the package first: `npm install nanoeffects` (details in the [README](../README.md#install)). The levels A to D are described in the [map of the docs](README.md#the-ladder-from-a-to-f).
6
+
7
+ | Level | Your project | Stop after |
8
+ | ----- | ------------ | ---------- |
9
+ | A | Widget / prototype | §1–2 |
10
+ | B | SPA with async UI | §3–4 |
11
+ | C | Unreliable network / long flows | §5–6 |
12
+ | D | Domain logic off the UI | §7–9 |
11
13
 
12
14
  Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
13
15
 
@@ -22,7 +24,7 @@ Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
22
24
  - [5. Add retry and timeout](#5-add-retry-and-timeout)
23
25
  - [6. Run work in parallel or wait for an event](#6-run-work-in-parallel-or-wait-for-an-event)
24
26
  - [7. Name events three ways](#7-name-events-three-ways)
25
- - [8. Bind a handler to an action later](#8-bind-a-handler-to-an-action-later)
27
+ - [8. Replace a handler on the fly](#8-replace-a-handler-on-the-fly)
26
28
  - [9. Mount a domain as a module](#9-mount-a-domain-as-a-module)
27
29
  - [10. Add types when you want checking (optional)](#10-add-types-when-you-want-checking-optional)
28
30
  - [11. Isolate errors and warn on typos](#11-isolate-errors-and-warn-on-typos)
@@ -48,7 +50,12 @@ bus.emit("greet", "Bob"); // nobody listens
48
50
 
49
51
  Handlers run at once, in subscription order.
50
52
 
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. `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`.
53
+ Four more calls cover most of what you need from the bus itself:
54
+
55
+ - `bus.once(event, handler)` is like `on`, but the handler runs at most once (it is removed before it is invoked).
56
+ - `bus.off("greet")` clears every listener of that event.
57
+ - `bus.off()` with no arguments clears **every** listener on the bus, `"*"` included. `ctx.off` is the same `off` as the bus's, so `ctx.off()` also clears the whole bus.
58
+ - `bus.get("greet")` gives the listeners that event has right now, so `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
59
 
53
60
  ```js
54
61
  import { createBus } from "nanoeffects";
@@ -66,6 +73,8 @@ bus.off(); // clear every subscription
66
73
 
67
74
  ### Listen to every event
68
75
 
76
+ You can skip this and the next section on a first read.
77
+
69
78
  `bus.on("*", fn)` runs for every event of the bus. It is for logging, debugging, or forwarding events elsewhere. The handler gets the payload and the task context as usual, and `ctx.type` is the name of the event.
70
79
 
71
80
  ```js
@@ -88,7 +97,7 @@ unbind();
88
97
 
89
98
  ### Pick events with a function
90
99
 
91
- To listen to a group of events, such as every `user/*` event, `"*"` and a check of `ctx.type` do the job but leave the check to you. `matchPlugin` lets `on` take a function that picks the events. The function gets `{ type, payload }` of each event, and the handler runs when it returns true.
100
+ This is for level B and later. To listen to a group of events, such as every `user/*` event, `"*"` and a check of `ctx.type` do the job but leave the check to you. `matchPlugin` lets `on` take a function that picks the events. The function gets `{ type, payload }` of each event, and the handler runs when it returns true.
92
101
 
93
102
  <!-- docs-check: new -->
94
103
 
@@ -234,7 +243,7 @@ const searchLatest = latest(async (query, { signal }) => {
234
243
  bus.on("search", debounce(300, searchLatest));
235
244
  ```
236
245
 
237
- Full options: [Policies](policies.md). No bus? A policy also works as a plain function: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
246
+ Full options: [Policies](policies.md). No bus? A policy also works as a plain function: [`withPolicy`: call a policy outside a bus](policies.md#withpolicy-call-a-policy-outside-a-bus).
238
247
 
239
248
  ## 5. Add retry and timeout
240
249
 
@@ -289,7 +298,7 @@ import { all, delay, race, take } from "nanoeffects/effects";
289
298
  const bus = createBus();
290
299
 
291
300
  // all — run together; one failure cancels the rest
292
- // child — the context of that starter: it has the `signal`, so the request stops with the group
301
+ // child — the context of that request: it has the `signal`, so the request stops with the group
293
302
  bus.on("search", async (id, ctx) => {
294
303
  const { user, posts } = await all(ctx, {
295
304
  user: (child) => api.user(id, child),
@@ -364,9 +373,11 @@ bus.emit(double(21)); // 42
364
373
 
365
374
  Use strings for the plain case. Use objects when other code already builds actions. Give `createAction` a handler when you need a result from `emit`.
366
375
 
367
- ## 8. Bind a handler to an action later
376
+ ## 8. Replace a handler on the fly
377
+
378
+ **Problem.** The handler of an action is in the file of the feature, but a test needs a fake, or another layer owns the work. If the handler sits in `createAction`, the event file is tied to the work.
368
379
 
369
- An action may carry its handler, as `double` above, or get it later from a registry — another file, or a fake in a test. An action with no handler is an event until one is bound.
380
+ **What to do.** Create the action without a handler: it is an event. Bind the real handler with `setHandler` when the bus is made. Later, `actions.handle` binds a newer handler that wins until you remove it.
370
381
 
371
382
  ```js
372
383
  // user.js
@@ -378,26 +389,32 @@ export const getUser = createAction("user/get"); // no handler yet: an event
378
389
  ```js
379
390
  // bus.js
380
391
  import { createBus } from "nanoeffects";
381
- import { actionsPlugin } from "nanoeffects/actions";
392
+ import { actionsPlugin, setHandler } from "nanoeffects/actions";
393
+ import { getUser } from "./user";
382
394
 
383
395
  export const bus = createBus();
384
- // actionsPlugin — registry of handlers for this bus
385
- export const actions = bus.use(actionsPlugin());
396
+
397
+ // setHandler — binds the real handler to the action
398
+ const realUser = setHandler(getUser, (id) => api.user(id));
399
+
400
+ // actionsPlugin — the registry of bound handlers of this bus
401
+ export const actions = bus.use(actionsPlugin([realUser]));
386
402
  ```
387
403
 
388
404
  ```js
389
405
  import { bus, actions } from "./bus";
390
406
  import { getUser } from "./user";
391
407
 
392
- bus.emit(getUser("1")); // nothing is bound: only an event
408
+ const user = await bus.emit(getUser("1")); // the real handler answers
393
409
 
394
- const unregister = actions.handle(getUser, (id) => api.user(id));
395
- const user = await bus.emit(getUser("1"));
410
+ // actions.handle — a newer binding wins, for example a fake in a test
411
+ const restore = actions.handle(getUser, (id) => ({ id, name: "test" }));
412
+ const fake = await bus.emit(getUser("1")); // the fake answers
396
413
 
397
- unregister();
414
+ restore(); // the real handler answers again
398
415
  ```
399
416
 
400
- Each bus has its own registry. A [module](#9-mount-a-domain-as-a-module) can register handlers and remove them on unmount. More in [Actions](actions.md).
417
+ Each bus has its own registry. A [module](#9-mount-a-domain-as-a-module) can bind handlers and remove them on unmount. More in [Actions](actions.md#bind-a-handler-later).
401
418
 
402
419
  ## 9. Mount a domain as a module
403
420
 
package/docs/hmr.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Hot reload and development diagnostics
2
2
 
3
+ > **Level F.** Read first: [Actions, jobs and modules](actions.md).
4
+
3
5
  Use this page in development with modules and hot reload. Skip until you mount domains with `modulePlugin`.
4
6
 
5
7
  When a file is edited, the bundler loads its new version and runs it again, while the page and the rest of the code stay alive. For a bus that can go wrong in three ways:
@@ -37,11 +39,43 @@ bus.use(devPlugin());
37
39
 
38
40
  - **Listeners that pile up.** When more than `maxListeners` (default `10`) listeners of one event were added and not unsubscribed, one warning names the event and the count. The count follows the function that `on` returns; `bus.off(event, fn)` is not seen.
39
41
  - **Modules.** For the app of `modulePlugin` (or the `app` option) it logs `module "user" mounted`, `replaced` when a module of the same name was mounted, and `unmounted`. Only what goes through `app.mount` and `app.unmount` is reported, not sub-modules.
40
- - **Options.** `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
42
+ - **Where a named listener was made.** For a listener with a `traceId`, it notes the file and the line of the `bus.on` call, while the listener is subscribed. [`graphLogger`](debugging-loops.md#read-a-report) shows it in a loop report, so you can open the handler that closes the loop.
43
+ - **Handlers that ignore the signal.** When a handler emits after its task was aborted (a newer call of `latest`, an unmounted module), the bus drops that emit. One warning then names the handler and the event: the handler did not stop when it was cancelled, usually because `ctx.signal` never reached the request. [Details](#find-a-handler-that-ignores-the-signal)
44
+ - **Options.** `abandoned: false`, `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
41
45
  - **Disconnect.** `bus.use(devPlugin(...))` returns the function that stops the diagnostics and puts `bus.on` and `app.mount` back.
42
46
 
43
47
  It changes no behavior: it wraps `bus.on`, `app.mount`, and `app.unmount` and adds messages.
44
48
 
49
+ ### Find a handler that ignores the signal
50
+
51
+ A policy such as `latest` cancels the older call by aborting its `ctx.signal`. A handler that does not pass the signal to its request keeps running, and its result arrives after the newer one. The policy drops that result, so nothing looks wrong until the handler does something else with it.
52
+
53
+ ```js
54
+ import { createBus } from "nanoeffects";
55
+ import { latest } from "nanoeffects/policies";
56
+
57
+ const bus = createBus();
58
+
59
+ bus.on(
60
+ "search",
61
+ latest(async (query, ctx) => {
62
+ // the signal is not passed, so an older request is not cancelled
63
+ const items = await fetch(`/api?q=${query}`).then((response) => response.json());
64
+ ctx.emit("results", items);
65
+ }),
66
+ );
67
+ ```
68
+
69
+ With `devPlugin`, the second `search` aborts the first call. When the first call then emits `results`, the bus drops the emit and the plugin warns once:
70
+
71
+ ```text
72
+ [nanoeffects] the handler of "search" emitted "results" after its task was aborted, so the emit was dropped. The handler kept running after it was cancelled: pass ctx.signal to the request (fetch(url, { signal: ctx.signal })) or give ctx to an effect (retry(ctx, ...)).
73
+ ```
74
+
75
+ The check lives in the plugin only: the policies and the bus carry no code for it, and in production `nanoeffects/dev` is empty. It sees the handlers that emit or subscribe with `ctx.on` after the cancel; a handler that never does is not seen.
76
+
77
+ A linter finds the same mistake before the code runs, and much more of it: a handler with no `ctx`, a step that does not get the signal while another does, the calls of `Promise.all`, the callbacks of `retry` and `timeout`, a pause, a stream, `bus.emit` in a handler. [Keep the signal through a handler](pass-the-signal.md) lists every situation, what catches it and how to fix it, and shows the setup for ESLint, oxlint and Biome.
78
+
45
79
  ### Keep the import in production
46
80
 
47
81
  `nanoeffects/dev` is a no-op in a production build. `package.json` maps it to an empty build under the `production` export condition (Vite and webpack set that in production mode), so `bus.use(devPlugin())` can stay without a guard: it touches nothing and costs a few bytes.
@@ -0,0 +1,109 @@
1
+ # Cut a loop with `loopGuardPlugin`
2
+
3
+ > **Level F.** Read first: [Plugins](plugins.md#use-a-plugin). To find a loop before you cut it, see [Find and cut event loops](debugging-loops.md).
4
+
5
+ Two handlers that emit each other's events never stop. A loop made of `await` and timers burns CPU and network. A loop made of microtasks freezes the page, because the browser never gets a turn to draw. A report cannot stop any of them. `loopGuardPlugin` can: it follows the chain of emits and refuses the emit that would pass a limit.
6
+
7
+ Use it as a safety net, also in production. It needs no tracer and changes nothing while a chain stays under the limits. It is opt-in: a bus that does not use it behaves as before.
8
+
9
+ ## On this page
10
+
11
+ - [Cut a loop](#cut-a-loop)
12
+ - [Choose what happens at the limit](#choose-what-happens-at-the-limit)
13
+ - [Limit the emits that come from outside](#limit-the-emits-that-come-from-outside)
14
+ - [Leave a recursion out](#leave-a-recursion-out)
15
+ - [Know what it does not do](#know-what-it-does-not-do)
16
+
17
+ ## Cut a loop
18
+
19
+ The plugin counts how many times the same event is in the chain of emits that led to an emit. The chain follows `ctx.emit`, also across `await`, timers and microtasks. An emit that would pass a limit is refused, and the loop ends there. Other chains go on, and the next emit from outside starts a chain at zero.
20
+
21
+ ```js
22
+ // bus.js
23
+ import { createBus } from "nanoeffects";
24
+ import { loopGuardPlugin } from "nanoeffects/guard";
25
+
26
+ // loopGuardPlugin — refuses the emit that would pass a limit of a chain
27
+ export const bus = createBus().use(
28
+ loopGuardPlugin({
29
+ maxRepeats: 50, // one event may be in a chain 50 times
30
+ maxDepth: 1000, // a chain may be 1000 emits long, whatever the names are
31
+ }),
32
+ );
33
+ ```
34
+
35
+ | Option | Default | Cuts |
36
+ | ------ | ------- | ---- |
37
+ | `maxRepeats` | 50 | The same event in one chain too often: a loop. |
38
+ | `maxDepth` | 1000 | A chain that is too long, also when every event has a new name (`step/${n}`). |
39
+ | `maxPerSecond` | none | Emits of one event from outside any handler that come too fast ([below](#limit-the-emits-that-come-from-outside)). |
40
+ | `ignore` | none | Event names that the repeat count skips ([below](#leave-a-recursion-out)). |
41
+
42
+ ## Choose what happens at the limit
43
+
44
+ `onLimit` says what the refused emit does. Either way the plugin reports the finding once for each path. The report has the path of the loop and never the payload.
45
+
46
+ ```js
47
+ // bus.js
48
+ import { createBus } from "nanoeffects";
49
+ import { LoopError, loopGuardPlugin } from "nanoeffects/guard";
50
+
51
+ export const bus = createBus().use(
52
+ loopGuardPlugin({
53
+ onLimit: "drop", // "drop" (default): the emit is not sent; "throw": it throws a LoopError
54
+ // report — replaces the default console.error
55
+ report: (finding) => console.error(finding.limit, finding.path.join(" → ")),
56
+ }),
57
+ );
58
+
59
+ // with onLimit: "throw", the emit that was refused throws, and the error names the path
60
+ try {
61
+ bus.emit("cart/changed");
62
+ } catch (error) {
63
+ if (error instanceof LoopError) {
64
+ console.log(error.limit, error.path);
65
+ }
66
+ }
67
+ ```
68
+
69
+ - **`"drop"`** is the default. The emit returns as if nobody listened, so no new error appears in an async handler.
70
+ - **`"throw"`** shows the loop with a stack. In an async handler the error becomes a rejection: handle it with `onError`.
71
+
72
+ A loop that is cut can leave the state of the app half done: the handlers that ran have done their part, the ones after the cut have not. Fix the loop, and keep the guard as a net.
73
+
74
+ ## Limit the emits that come from outside
75
+
76
+ A component that emits on every render has no chain: each emit comes from outside any handler. `maxPerSecond` counts them for each event.
77
+
78
+ ```js
79
+ // bus.js
80
+ import { createBus } from "nanoeffects";
81
+ import { loopGuardPlugin } from "nanoeffects/guard";
82
+
83
+ // maxPerSecond — over 100 emits of one event in a second from outside are refused
84
+ export const bus = createBus().use(loopGuardPlugin({ maxPerSecond: 100 }));
85
+ ```
86
+
87
+ It is off by default, because a burst of many emits can be right (a stream of messages).
88
+
89
+ ## Leave a recursion out
90
+
91
+ A chain that repeats on purpose is cut too. A handler that emits `tree/visit` for each level of a tree, or one that emits `tick` again after a timer for hours, passes `maxRepeats`. List its event in `ignore`. `maxDepth` still applies to it.
92
+
93
+ ```js
94
+ // bus.js
95
+ import { createBus } from "nanoeffects";
96
+ import { loopGuardPlugin } from "nanoeffects/guard";
97
+
98
+ export const bus = createBus().use(
99
+ loopGuardPlugin({
100
+ ignore: ["tree/visit"], // names, or a function of the name
101
+ }),
102
+ );
103
+ ```
104
+
105
+ ## Know what it does not do
106
+
107
+ - **It does not abort the chain.** It refuses one emit, at the edge where the limit is passed. The handlers that already run, run to the end.
108
+ - **It follows `ctx.emit`.** A `bus.emit` called from inside a handler is not part of the chain: it starts a new one with the counts at zero. Use `ctx.emit` inside handlers.
109
+ - **It does not find a loop.** To see the path of a loop, and loops that go through components, use `graphLogger` ([Find and cut event loops](debugging-loops.md)).
@@ -1,5 +1,7 @@
1
1
  # Coming from Effector
2
2
 
3
+ > **Level E, for readers who know Effector.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4
+
3
5
  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.
4
6
 
5
7
  ## On this page
@@ -31,7 +33,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
31
33
  | event call `loaded(user)` | `bus.emit("loaded", user)` |
32
34
  | `createEffect(handler)` | a handler: `bus.on(event, handler)` |
33
35
  | effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
34
- | `effect.pending` | `job.pending`, `job.success`, `job.fail` and `job.cancel`: set and clear the flag in your store ([Show a loading state](actions.md#show-a-loading-state-with-a-job)) |
36
+ | `effect.pending` | `job.pending`, `job.success`, `job.fail` and `job.cancel`: set and clear the flag in your store ([Show a loading state](actions.md#the-state-of-a-job)) |
35
37
  | `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
36
38
  | `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
37
39
  | `sample({ filter })` | an `if` in the handler |
@@ -329,7 +331,7 @@ bus.on(
329
331
  ## What is different
330
332
 
331
333
  - **No state.** There is no `createStore`, `combine`, `restore`, `$store.map`. The bus holds listeners only. Take a store such as Zustand for state ([Stores](stores.md)).
332
- - **`pending` is an action, not a store.** `createJob` sends `pending` when the handler starts, and `success`, `fail` or `cancel` when it ends; you keep the flag in your store ([Show a loading state](actions.md#show-a-loading-state-with-a-job)). Policies report to the job, so the flag also follows `latest`, `leading`, `debounce`, `throttle` and `queue`; that section says when to count instead. A running task is also visible through [Tracing](tracing.md).
334
+ - **`pending` is an action, not a store.** `createJob` sends `pending` when the handler starts, and `success`, `fail` or `cancel` when it ends; you keep the flag in your store ([Show a loading state](actions.md#the-state-of-a-job)). Policies report to the job, so the flag also follows `latest`, `leading`, `debounce`, `throttle` and `queue`; that section says when to count instead. A running task is also visible through [Tracing](tracing.md).
333
335
  - **Cancellation is the default tool.** An Effector effect runs every call to the end. Here `latest` and `ctx.signal` stop what is no longer needed.
334
336
  - **Events are matched by name, not by identity.** An event is a string or a `createAction` creator, not a unit that you import and call. Two modules agree on the name.
335
337
  - **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
@@ -1,6 +1,8 @@
1
- # Coming from mitt, nanoevents or EventEmitter
1
+ # Coming from mitt and nanoevents
2
2
 
3
- Read this page if your app uses a small event emitter (`mitt`, `nanoevents`, Node's `EventEmitter`, `tiny-emitter`) and its handlers start to do async work: requests, timers, retries. It maps the calls, shows what you gain, and shows what to check before you switch.
3
+ > **Level A, for readers who know mitt or nanoevents.** Read first: nothing: the bus is the same idea.
4
+
5
+ Read this page if your app uses a small event emitter (`mitt`, `nanoevents`, `tiny-emitter`) and its handlers start to do async work: requests, timers, retries. It maps the calls, shows what you gain, and shows what to check before you switch.
4
6
 
5
7
  ## When to switch
6
8
 
@@ -15,8 +17,8 @@ If handlers stay synchronous, keep your emitter. nanoeffects costs about 1.4 kB
15
17
 
16
18
  ## Find the equivalent
17
19
 
18
- | mitt / EventEmitter | nanoeffects |
19
- | ------------------- | ----------- |
20
+ | mitt / nanoevents | nanoeffects |
21
+ | ----------------- | ----------- |
20
22
  | `emitter.on("a", handler)` | `bus.on("a", handler)` |
21
23
  | `emitter.off("a", handler)` | call the function that `bus.on` returned, or `bus.off("a")` for all handlers of `a` |
22
24
  | `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
@@ -1,5 +1,7 @@
1
1
  # Use nanoeffects next to RxJS
2
2
 
3
+ > **Level E, for readers who know RxJS.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4
+
3
5
  Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows a search box and a polling loop both ways, and lists what stays in RxJS.
4
6
 
5
7
  ## Split the work
@@ -1,5 +1,7 @@
1
1
  # Coming from redux-saga
2
2
 
3
+ > **Level E, for readers who know redux-saga.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4
+
3
5
  Read this page if you know redux-saga and want to find the same job in nanoeffects. It maps the names, shows one saga rewritten, and lists what has no equivalent. It does not teach the library: start with the [Guide](guide.md).
4
6
 
5
7
  ## Find the equivalent
@@ -1,16 +1,22 @@
1
1
  # Why nanoeffects exists
2
2
 
3
- Read this page to see why the library exists and when to pick it. The main reason is to take business logic and side effects out of React, Vue or Svelte components and write them as plain `async` functions, with cancellation, retries and a context for dependencies. The second part of the page is for SDKs, core libraries and micro-frontend shells, where a shared dependency is a shared risk. It does not teach the API: start with the [Guide](guide.md).
3
+ > **Level A.** Read first: [README](../README.md).
4
4
 
5
- ## The main reason: logic outside the UI
5
+ Read this page to see why the library exists. It moves business logic and side effects out of React, Vue or Svelte components into plain `async` functions, with cancellation, retries and a context for dependencies. It does not teach the API: start with the [Guide](guide.md).
6
+
7
+ ## Moving logic out of the UI
6
8
 
7
9
  Components that hold requests, timers and races are hard to test and easy to break. The usual cure is services and pure functions outside the UI, and then every service needs the same things: to cancel work that is no longer wanted, to retry, to share dependencies, and to show what ran and why.
8
10
 
9
- The common tools ask a lot: redux-saga writes this logic as generators, and RxJS as streams and operators. nanoeffects keeps ordinary `async` functions and adds one idea, the `AbortSignal`. The event bus is its heart but not its value: any emitter can send events (`nanoevents` is about 100 bytes, and there are [dozens more](https://github.com/gajus/sister#similar-libraries)). What nanoeffects adds is cancellable tasks, policies and effects, a context that works as a small container for dependencies, actions and modules to group a feature, and [tracing](tracing.md) with an adapter for Redux DevTools. It also fits what a project already has: recipes and small adapters for [stores](stores.md) (Redux Toolkit, Zustand, MobX, Pinia), an [RxJS](migrating-from-rxjs.md) bridge, and helpers for [React](react.md), [Vue](vue.md) and [Svelte](svelte.md), so nothing has to be rewritten to start.
11
+ The common tools ask a lot: redux-saga writes this logic as generators, and RxJS as streams and operators. nanoeffects keeps ordinary `async` functions and adds one idea, the `AbortSignal`.
12
+
13
+ Another way to take logic out of the UI is a dependency injection (DI) container, such as the one in Angular or in any DI library. nanoeffects has no container, but it can do dependency injection at the level of the contract: a handler takes what it needs from `ctx`, and the bus decides what to give. See [Context fields](context.md).
10
14
 
11
- ## When the constraint is a shared library
15
+ The event bus is the heart of the library but not its value: any emitter can send events (`nanoevents` is about 100 bytes, and there are [dozens more](https://github.com/gajus/sister#similar-libraries)). What nanoeffects adds is cancellable tasks, policies and effects, a context that works as a small container for dependencies, actions and modules to group a feature, and [tracing](tracing.md) with an adapter for Redux DevTools. It also fits what a project already has: recipes and small adapters for [stores](stores.md) (Redux Toolkit, Zustand, MobX, Pinia), an [RxJS](migrating-from-rxjs.md) bridge, and helpers for [React](react.md), [Vue](vue.md) and [Svelte](svelte.md), so nothing has to be rewritten to start.
12
16
 
13
- A shared library (an SDK, a core package, a platform shell) is loaded by many apps. Whatever it depends on, the apps depend on too. State and event libraries are the worst case, because they keep **internal state and identity** (a store, a context, a class). Two copies or two versions of them do not merge: they silently stop seeing each other.
17
+ ## Micro-frontends
18
+
19
+ In micro-frontends every app can bring its own versions of the same libraries. State and event libraries are the worst case, because they keep **internal state and identity** (a store, a context, a class). Two copies or two versions of them do not merge: they silently stop seeing each other. Different versions and copies also do more than work out of step: they load more code and use more resources than one agreed copy would. So the goal is a project that is as simple as possible, with as few shared dependencies that hold state as possible.
14
20
 
15
21
  Module Federation makes this visible. Each remote can bring its own copy unless every team agrees on a `singleton` and a version range. The [Nx guide on library versions](https://nx.dev/docs/kb/manage-library-versions-with-module-federation) lists the result: libraries with internal state conflict, and a missing method in one version breaks callers written for another.
16
22
 
@@ -23,25 +29,12 @@ Real cases from the libraries nanoeffects was compared with:
23
29
  | Effector | A library that uses `effector-react/scope` and is bundled into another project gets two instances. Its `Provider` is not seen by `useStore` of the app: "no scope found". | [effector#727](https://github.com/effector/effector/issues/727) |
24
30
  | RxJS | Two copies give two `Observable` classes. Types of one version do not fit the other, and operators added to one copy are missing on the other. The advice is "one version in `node_modules`". | [RxJS#2984](https://github.com/ReactiveX/rxjs/issues/2984), [neo4j-javascript-driver#978](https://github.com/neo4j/neo4j-javascript-driver/issues/978) |
25
31
 
26
- A library that is **not** a shared singleton cannot break this way. nanoeffects has no dependencies and no module-level state: a bus is an object you create. Two copies, or two versions, of the package in one page are two independent buses. Nothing is registered globally, no `Provider` is needed, and the host app does not have to pick a version for you.
27
-
28
- ## Why not the alternatives
32
+ nanoeffects has no dependencies and no module-level state: a bus is an object you create. Two copies, or two versions, of the package in one page are two independent buses. Nothing is registered globally, no `Provider` is needed, and the host app does not have to pick a version for you.
29
33
 
30
- | Option | Why it did not fit |
31
- | ------ | ------------------ |
32
- | Redux (with sagas) | A store and a provider shared by every consumer, which is the risk above. `redux-saga` also has no cheap way to get an answer back from an event: effects talk through the store. |
33
- | Effector | A graph with global identity. A version or scope mismatch between the SDK and the app reaches the whole infrastructure. |
34
- | Zustand | Good state, but it is a store, not a way to run and cancel work. It is the natural place to hold state next to nanoeffects: see [Stores](stores.md). |
35
- | RxJS | Flexible, but learning its ecosystem (operators, subjects, schedulers) is a high entry cost for every developer who touches the SDK. The package is also not small: [Bundlephobia](https://bundlephobia.com/package/rxjs) reports 17.3 kB minified and gzipped for 7.8.2. |
36
-
37
- ## What the library does instead
34
+ ## What the library does
38
35
 
39
36
  - **No dependencies, no global state.** It is safe to ship inside an SDK next to whatever the host app runs. It does not need a `singleton` entry in the Module Federation config.
40
37
  - **Small enough for a strict budget.** SDKs often get a few kilobytes for all third-party code. `createBus` is about 1.4 kB gzip (1.3 kB brotli); policies, effects and actions are separate entries you import only when you need them. See [Package entries](package.md).
41
- - **Simple first.** `on` and `emit` are the whole model. Cancellation, retry and the rest are plain functions on top. The [Guide](guide.md) and [Coming from redux-saga](migrating-from-saga.md) map the familiar names.
38
+ - **Easy to start.** `on` and `emit` are the whole model. Cancellation, retry and the rest are plain functions on top. The [Guide](guide.md) and [Coming from redux-saga](migrating-from-saga.md) map the familiar names.
42
39
  - **`emit` can return an answer.** An [action with a handler](actions.md) returns a result, so one module can ask another and wait for the reply. Events alone cannot do that, and it was the main thing missing from the Redux-based options.
43
40
  - **Easy to debug.** Event-driven code is hard to follow: you see an effect and not the event that caused it. Every task knows its parent, so [tracing](tracing.md) shows the chain of events, with loggers for the console, the Performance panel and Redux DevTools.
44
-
45
- ## When this is not the right tool
46
-
47
- nanoeffects holds no state. If you need a store, keep Redux, Zustand or Effector and let the bus run the async work: [Stores](stores.md). If you already have streams everywhere, keep RxJS: [Use nanoeffects next to RxJS](migrating-from-rxjs.md).
@@ -1,5 +1,7 @@
1
1
  # Turn events into Observables
2
2
 
3
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
4
+
3
5
  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.
4
6
 
5
7
  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).
package/docs/oop.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Use the bus from a class
2
2
 
3
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
4
+
3
5
  Use this page when **your domain logic lives in classes**: a service, a store or a controller that needs the bus inside its methods. There are two ways to give a class the methods of a bus, and both remember what each instance subscribed to, so `dispose()` ends it all:
4
6
 
5
7
  | You want | Use |
@@ -7,7 +9,7 @@ Use this page when **your domain logic lives in classes**: a service, a store or
7
9
  | every `new Service()` to be an independent emitter | `extends Emitter` |
8
10
  | the instances to share a bus, or to use plugins | `extends extendBus(bus)` |
9
11
 
10
- If your logic is plain functions, stay with [modules](actions.md#mount-domains-with-modules): a module is the same idea without a class.
12
+ If your logic is plain functions, stay with [modules](actions.md#modules): a module is the same idea without a class.
11
13
 
12
14
  ## On this page
13
15
 
package/docs/package.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Choose a package entry
2
2
 
3
+ > **Levels A to F.** Read first: [README](../README.md).
4
+
3
5
  Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
4
6
 
5
7
  ## Start with these three
@@ -28,7 +30,8 @@ Each row is optional. The "when" column says what need brings you to it.
28
30
  | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
29
31
  | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
30
32
  | `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
31
- | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) | you want to see what ran and why |
33
+ | `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops) and `tracePlugin` (names for the trace) | you want to see what ran and why |
34
+ | `nanoeffects/guard` | `loopGuardPlugin`: refuses the emit that would pass a limit of a chain | a loop must not take the app down |
32
35
 
33
36
  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.
34
37