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
package/llms-full.txt CHANGED
@@ -9,7 +9,7 @@ Rules that generated code often gets wrong:
9
9
  - A handler gets the payload and a task context: `(payload, ctx)`. The context has `signal`, `emit`, `on`, `run`. Pass `ctx.signal` to `fetch`.
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
- - 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`.
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, `graphLogger`, `tracePlugin`), `nanoeffects/guard` (`loopGuardPlugin`), `nanoeffects/dev`.
13
13
  - `bus.emit(action(payload))` runs the action's handler and returns its result. Plain events return nothing.
14
14
 
15
15
  ---
@@ -17,14 +17,16 @@ Source: docs/guide.md
17
17
 
18
18
  # Guide
19
19
 
20
- Climb one step at a time: small bus → policies → effects → handlers and modules. Stop when your project has what it needs.
20
+ > **Levels A to D.** Read first: [README](../README.md) (install and the first example).
21
21
 
22
- | Your project | Stop after |
23
- | ------------ | ---------- |
24
- | Widget / prototype | §1–2 |
25
- | SPA with async UI | §3–4 |
26
- | Unreliable network / long flows | §5–6 |
27
- | Domain logic off the UI | §7–9 |
22
+ 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).
23
+
24
+ | Level | Your project | Stop after |
25
+ | ----- | ------------ | ---------- |
26
+ | A | Widget / prototype | §1–2 |
27
+ | B | SPA with async UI | §3–4 |
28
+ | C | Unreliable network / long flows | §5–6 |
29
+ | D | Domain logic off the UI | §7–9 |
28
30
 
29
31
  Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
30
32
 
@@ -39,7 +41,7 @@ Runnable code for every step: [`examples/guide.ts`](../examples/guide.ts).
39
41
  - [5. Add retry and timeout](#5-add-retry-and-timeout)
40
42
  - [6. Run work in parallel or wait for an event](#6-run-work-in-parallel-or-wait-for-an-event)
41
43
  - [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)
44
+ - [8. Replace a handler on the fly](#8-replace-a-handler-on-the-fly)
43
45
  - [9. Mount a domain as a module](#9-mount-a-domain-as-a-module)
44
46
  - [10. Add types when you want checking (optional)](#10-add-types-when-you-want-checking-optional)
45
47
  - [11. Isolate errors and warn on typos](#11-isolate-errors-and-warn-on-typos)
@@ -65,7 +67,12 @@ bus.emit("greet", "Bob"); // nobody listens
65
67
 
66
68
  Handlers run at once, in subscription order.
67
69
 
68
- `bus.once` is like `on`, but the handler runs at most once (it is removed before it is invoked). `bus.off("greet")` clears every listener of that event; `bus.off()` with no arguments clears **every** listener on the bus (including `"*"`). `ctx.off` is the same `off` as the bus’s — so `ctx.off()` also clears the whole bus. `bus.get("greet")` gives the listeners that event has right now; `bus.get("greet").length === 0` tells that nobody listens any more (`"*"` gives the any-event listeners, which a named event does not include; `bus.get()` with no name gives every listener of every event). It takes the event name as a string: for an action creator use `creator.type`.
70
+ Four more calls cover most of what you need from the bus itself:
71
+
72
+ - `bus.once(event, handler)` is like `on`, but the handler runs at most once (it is removed before it is invoked).
73
+ - `bus.off("greet")` clears every listener of that event.
74
+ - `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.
75
+ - `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`.
69
76
 
70
77
  ```js
71
78
  import { createBus } from "nanoeffects";
@@ -83,6 +90,8 @@ bus.off(); // clear every subscription
83
90
 
84
91
  ### Listen to every event
85
92
 
93
+ You can skip this and the next section on a first read.
94
+
86
95
  `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.
87
96
 
88
97
  ```js
@@ -105,7 +114,7 @@ unbind();
105
114
 
106
115
  ### Pick events with a function
107
116
 
108
- 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.
117
+ 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.
109
118
 
110
119
  <!-- docs-check: new -->
111
120
 
@@ -251,7 +260,7 @@ const searchLatest = latest(async (query, { signal }) => {
251
260
  bus.on("search", debounce(300, searchLatest));
252
261
  ```
253
262
 
254
- 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).
263
+ 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).
255
264
 
256
265
  ## 5. Add retry and timeout
257
266
 
@@ -306,7 +315,7 @@ import { all, delay, race, take } from "nanoeffects/effects";
306
315
  const bus = createBus();
307
316
 
308
317
  // 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
318
+ // child — the context of that request: it has the `signal`, so the request stops with the group
310
319
  bus.on("search", async (id, ctx) => {
311
320
  const { user, posts } = await all(ctx, {
312
321
  user: (child) => api.user(id, child),
@@ -381,9 +390,11 @@ bus.emit(double(21)); // 42
381
390
 
382
391
  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`.
383
392
 
384
- ## 8. Bind a handler to an action later
393
+ ## 8. Replace a handler on the fly
385
394
 
386
- 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.
395
+ **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.
396
+
397
+ **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.
387
398
 
388
399
  ```js
389
400
  // user.js
@@ -395,26 +406,32 @@ export const getUser = createAction("user/get"); // no handler yet: an event
395
406
  ```js
396
407
  // bus.js
397
408
  import { createBus } from "nanoeffects";
398
- import { actionsPlugin } from "nanoeffects/actions";
409
+ import { actionsPlugin, setHandler } from "nanoeffects/actions";
410
+ import { getUser } from "./user";
399
411
 
400
412
  export const bus = createBus();
401
- // actionsPlugin — registry of handlers for this bus
402
- export const actions = bus.use(actionsPlugin());
413
+
414
+ // setHandler — binds the real handler to the action
415
+ const realUser = setHandler(getUser, (id) => api.user(id));
416
+
417
+ // actionsPlugin — the registry of bound handlers of this bus
418
+ export const actions = bus.use(actionsPlugin([realUser]));
403
419
  ```
404
420
 
405
421
  ```js
406
422
  import { bus, actions } from "./bus";
407
423
  import { getUser } from "./user";
408
424
 
409
- bus.emit(getUser("1")); // nothing is bound: only an event
425
+ const user = await bus.emit(getUser("1")); // the real handler answers
410
426
 
411
- const unregister = actions.handle(getUser, (id) => api.user(id));
412
- const user = await bus.emit(getUser("1"));
427
+ // actions.handle — a newer binding wins, for example a fake in a test
428
+ const restore = actions.handle(getUser, (id) => ({ id, name: "test" }));
429
+ const fake = await bus.emit(getUser("1")); // the fake answers
413
430
 
414
- unregister();
431
+ restore(); // the real handler answers again
415
432
  ```
416
433
 
417
- 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).
434
+ 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).
418
435
 
419
436
  ## 9. Mount a domain as a module
420
437
 
@@ -530,11 +547,545 @@ More in [Errors](errors.md).
530
547
  | Guarantees | [Semantics](semantics.md) |
531
548
  | Which import | [Package entries](package.md) |
532
549
 
550
+ ---
551
+ Source: docs/recipes.md
552
+
553
+ # Common tasks
554
+
555
+ > **Levels A to E.** Read first: [README](../README.md).
556
+
557
+ Each task below starts with the problem, then a short solution. Where the page has more to say, the last line links to it. The tasks go from the simplest to the most involved.
558
+
559
+ ## On this page
560
+
561
+ - [Events and handlers](#events-and-handlers)
562
+ - [A handler needs an API client, a store or a logger](#a-handler-needs-an-api-client-a-store-or-a-logger)
563
+ - [One handler fails, the others must still run](#one-handler-fails-the-others-must-still-run)
564
+ - [A typo in an event name](#a-typo-in-an-event-name)
565
+ - [Races and repeated actions in the UI](#races-and-repeated-actions-in-the-ui)
566
+ - [An old response overwrites a new one](#an-old-response-overwrites-a-new-one)
567
+ - [A search box sends a request on every key](#a-search-box-sends-a-request-on-every-key)
568
+ - [A second click sends a second request](#a-second-click-sends-a-second-request)
569
+ - [Messages must go out in order](#messages-must-go-out-in-order)
570
+ - [A "Delete" button in a list blocks the other rows](#a-delete-button-in-a-list-blocks-the-other-rows)
571
+ - [An unreliable network and long flows](#an-unreliable-network-and-long-flows)
572
+ - [A request fails sometimes: retry and backoff](#a-request-fails-sometimes-retry-and-backoff)
573
+ - [A request hangs: timeout](#a-request-hangs-timeout)
574
+ - [Refresh data every N seconds: poll](#refresh-data-every-n-seconds-poll)
575
+ - [Wait for another event inside a handler: take](#wait-for-another-event-inside-a-handler-take)
576
+ - [Load several things at once: all](#load-several-things-at-once-all)
577
+ - [Take the fastest answer: race](#take-the-fastest-answer-race)
578
+ - [Features and domain logic](#features-and-domain-logic)
579
+ - [Get the result of emit](#get-the-result-of-emit)
580
+ - [Group a feature with its handlers: modules](#group-a-feature-with-its-handlers-modules)
581
+ - [Do not leave subscriptions after a widget closes](#do-not-leave-subscriptions-after-a-widget-closes)
582
+ - [Stop work on a server](#stop-work-on-a-server)
583
+ - [Debugging: what ran and why](#debugging-what-ran-and-why)
584
+ - [Other libraries in the project](#other-libraries-in-the-project)
585
+ - [Classes and TypeScript](#classes-and-typescript)
586
+ - [Use the bus in a class](#use-the-bus-in-a-class)
587
+ - [Keep the types of the bus in many files](#keep-the-types-of-the-bus-in-many-files)
588
+ - [Examples of whole apps](#examples-of-whole-apps)
589
+
590
+ ## Events and handlers
591
+
592
+ ### A handler needs an API client, a store or a logger
593
+
594
+ Many handlers use the same `api` client or logger. Importing it in every handler file ties the handlers to one client and makes tests harder. Put it on the bus once, and every task gets it on `ctx`.
595
+
596
+ ```js
597
+ import { createBus } from "nanoeffects";
598
+
599
+ // createBus({ context }) — every task gets these fields on ctx
600
+ const bus = createBus({ context: { api, logger } });
601
+
602
+ bus.on("save", async (draft, { api, logger }) => {
603
+ await api.save(draft);
604
+ logger.info("saved");
605
+ });
606
+ ```
607
+
608
+ More: [Context fields](context.md).
609
+
610
+ ### One handler fails, the others must still run
611
+
612
+ Two handlers listen to `order/placed`: one sends analytics, the other saves the order. By default the first error stops the loop, so a broken analytics call can keep the order from being saved. With `onError` every handler runs and the errors go to one place.
613
+
614
+ ```js
615
+ import { createBus } from "nanoeffects";
616
+
617
+ const bus = createBus({
618
+ // onError — isolates the handlers: a thrown error goes here and the next handler still runs
619
+ onError: (error, { type }) => report(error, type),
620
+ });
621
+
622
+ const sendAnalytics = (order) => {
623
+ throw new Error(`analytics is down for ${order.id}`);
624
+ };
625
+ const saveOrder = (order) => console.log("saved", order.id);
626
+
627
+ bus.on("order/placed", sendAnalytics); // throws
628
+ bus.on("order/placed", saveOrder); // still runs
629
+
630
+ bus.emit("order/placed", { id: 1 }); // saved 1
631
+ ```
632
+
633
+ More: [Errors and unheard events](errors.md#isolate-failing-handlers).
634
+
635
+ ### A typo in an event name
636
+
637
+ `emit("serch")` does not fail: no one listens to that name, so nothing happens, and you look for the bug in the handler. `onUnhandled` warns about an event that nobody heard. Turn it on in development.
638
+
639
+ ```js
640
+ import { createBus } from "nanoeffects";
641
+
642
+ const bus = createBus({
643
+ // onUnhandled — called when an event has no listeners
644
+ onUnhandled: ({ type }) => console.warn(`Nobody listens to "${type}"`),
645
+ });
646
+
647
+ bus.on("search", (query) => console.log(query));
648
+
649
+ bus.emit("serch", "nano"); // warning: Nobody listens to "serch"
650
+ ```
651
+
652
+ More: [Warn when nobody listens](errors.md#warn-when-nobody-listens).
653
+
654
+ ## Races and repeated actions in the UI
655
+
656
+ ### An old response overwrites a new one
657
+
658
+ A user types `re`, then `react`. Two requests are in flight. The server answers `react` fast and `re` slowly, so the answer for `re` arrives last and replaces the right list with the wrong one. This is what a plain React effect does:
659
+
660
+ ```jsx
661
+ import { useEffect, useState } from "react";
662
+
663
+ export function Search({ query }) {
664
+ const [items, setItems] = useState([]);
665
+
666
+ useEffect(() => {
667
+ // two requests are in flight, and the one that finishes last wins, even the older one
668
+ api.search(query).then(setItems);
669
+ }, [query]);
670
+
671
+ return (
672
+ <ul>
673
+ {items.map((item) => (
674
+ <li key={item.id}>{item.title}</li>
675
+ ))}
676
+ </ul>
677
+ );
678
+ }
679
+ ```
680
+
681
+ Move the request into a handler with `latest`: a new `search` aborts the previous request, and the aborted one delivers no result.
682
+
683
+ ```js
684
+ import { createBus } from "nanoeffects";
685
+ import { latest } from "nanoeffects/policies";
686
+
687
+ const bus = createBus();
688
+
689
+ // latest — a new "search" aborts the previous one and drops its result
690
+ bus.on(
691
+ "search",
692
+ latest(async (query, { signal }) => {
693
+ // signal — lets the browser cancel the request itself
694
+ const items = await api.search(query, { signal });
695
+ bus.emit("results", items);
696
+ }),
697
+ );
698
+
699
+ bus.on("results", (items) => render(items));
700
+ ```
701
+
702
+ More: [Cancel stale requests](cancel-stale-requests.md), [Policies](policies.md#latest-abort-the-previous-run), and the component side in [Build a sign-up form in React](react.md).
703
+
704
+ ### A search box sends a request on every key
705
+
706
+ Typing `react` sends five requests, and the list flickers. `debounce` waits for a pause in typing; `latest` aborts the request that is still in flight.
707
+
708
+ ```js
709
+ import { createBus } from "nanoeffects";
710
+ import { debounce, latest } from "nanoeffects/policies";
711
+
712
+ const bus = createBus();
713
+
714
+ // latest — a new search aborts the request in flight
715
+ const searchLatest = latest(async (query, { signal }) => {
716
+ const items = await api.search(query, { signal });
717
+ bus.emit("results", items);
718
+ });
719
+
720
+ // debounce — start after 300 ms without a new "typed"
721
+ bus.on("typed", debounce(300, searchLatest));
722
+ ```
723
+
724
+ More: [Pause between requests](debounce-and-abort.md).
725
+
726
+ ### A second click sends a second request
727
+
728
+ A user double-clicks "Sign up" on a slow network, and two accounts are created. `leading` drops every call that comes while the first one still runs.
729
+
730
+ ```js
731
+ import { createBus } from "nanoeffects";
732
+ import { leading } from "nanoeffects/policies";
733
+
734
+ const bus = createBus();
735
+
736
+ // leading — a call that comes while the previous one runs is dropped
737
+ bus.on(
738
+ "form/submitted",
739
+ leading(async (form, { signal }) => {
740
+ await api.signUp(form, { signal });
741
+ }),
742
+ );
743
+ ```
744
+
745
+ More: [Prevent a double submit](prevent-double-submit.md).
746
+
747
+ ### Messages must go out in order
748
+
749
+ In a chat, a user sends `Hello` and then `How are you?` within a second. Two parallel requests can reach the server in the wrong order. `queue` sends the next message only after the previous one is delivered.
750
+
751
+ ```js
752
+ import { createBus } from "nanoeffects";
753
+ import { queue } from "nanoeffects/policies";
754
+
755
+ const bus = createBus();
756
+
757
+ // queue — the next message waits until the previous one is delivered
758
+ bus.on(
759
+ "message/sent",
760
+ queue(async (message, { signal }) => {
761
+ await api.sendMessage(message, { signal });
762
+ }),
763
+ );
764
+ ```
765
+
766
+ More: [Policies, `queue`](policies.md#run-calls-one-after-another-with-queue).
767
+
768
+ ### A "Delete" button in a list blocks the other rows
769
+
770
+ You protect "Delete" with `leading`. While one row is deleted, clicks on the other rows are dropped too, because the whole handler has one state. `key` gives every row its own state.
771
+
772
+ ```js
773
+ import { createBus } from "nanoeffects";
774
+ import { leading } from "nanoeffects/policies";
775
+
776
+ const bus = createBus();
777
+
778
+ // leading with key — every row id has its own state, so other rows are not blocked
779
+ bus.on(
780
+ "row/deleted",
781
+ leading(
782
+ async (row, { signal }) => {
783
+ await api.deleteRow(row.id, { signal });
784
+ },
785
+ { key: (row) => row.id },
786
+ ),
787
+ );
788
+ ```
789
+
790
+ More: [Policies, `key`](policies.md#key-isolate-state-by-key).
791
+
792
+ ## An unreliable network and long flows
793
+
794
+ ### A request fails sometimes: retry and backoff
795
+
796
+ A request fails once in a while because of the network. `retry` repeats it. If the server is overloaded, repeating at once makes it worse, so use `backoff` to make the pause longer with each attempt.
797
+
798
+ ```js
799
+ import { createBus } from "nanoeffects";
800
+ import { backoff, retry } from "nanoeffects/effects";
801
+
802
+ const bus = createBus();
803
+
804
+ // retry — repeat the request after an error, up to 4 attempts
805
+ const loadWithRetry = retry((id, { signal }) => api.getUser(id, { signal }), {
806
+ times: 4,
807
+ // backoff — the pause grows (200, 400, 800 ms) with a random part, so clients do not retry at the same moment
808
+ delay: backoff({
809
+ base: 200,
810
+ max: 5000,
811
+ jitter: true,
812
+ }),
813
+ });
814
+
815
+ bus.on("user/opened", loadWithRetry);
816
+ ```
817
+
818
+ More: [`backoff`](effects.md#backoff).
819
+
820
+ ### A request hangs: timeout
821
+
822
+ A request does not fail and does not answer, and the spinner spins forever. `timeout` rejects the call after the time you set and aborts the request.
823
+
824
+ ```js
825
+ import { createBus } from "nanoeffects";
826
+ import { timeout } from "nanoeffects/effects";
827
+
828
+ const bus = createBus();
829
+
830
+ // timeout — reject with TimeoutError and abort the request after 5 s
831
+ const loadOnce = timeout(5000, (id, { signal }) => api.getUser(id, { signal }));
832
+
833
+ bus.on("user/opened", loadOnce);
834
+ ```
835
+
836
+ More: [`timeout`](effects.md#timeout).
837
+
838
+ ### Refresh data every N seconds: poll
839
+
840
+ A dashboard must refresh its numbers every ten seconds while it is open. `poll` is an effect: it calls your function on a timer and ends when the task is aborted, so a closed dashboard does not keep asking.
841
+
842
+ ```js
843
+ import { createBus } from "nanoeffects";
844
+ import { poll } from "nanoeffects/effects";
845
+
846
+ const bus = createBus();
847
+
848
+ bus.on("dashboard/opened", async (_, ctx) => {
849
+ // stop the polling when the user leaves
850
+ ctx.on("dashboard/closed", () => ctx.abort());
851
+
852
+ // poll — call refresh every 10 s until the task is aborted
853
+ await poll(ctx, () => refresh(), 10_000);
854
+ });
855
+ ```
856
+
857
+ More: [`poll`](effects.md#poll).
858
+
859
+ ### Wait for another event inside a handler: take
860
+
861
+ After a login the handler must wait for the user to log out before it cleans up. Instead of a flag and a second handler, `take` pauses the handler until the next event with that name.
862
+
863
+ ```js
864
+ import { createBus } from "nanoeffects";
865
+ import { take } from "nanoeffects/effects";
866
+
867
+ const bus = createBus();
868
+
869
+ bus.on("login", async (user, ctx) => {
870
+ // take — pause this handler until the next "logout"
871
+ const reason = await take(ctx, "logout");
872
+ cleanup(user, reason);
873
+ });
874
+ ```
875
+
876
+ More: [`take`](effects.md#take).
877
+
878
+ ### Load several things at once: all
879
+
880
+ A profile page needs the user and the posts. Loading them one after another is slow. `all` runs both requests together and aborts the second one if the first fails.
881
+
882
+ ```js
883
+ import { createBus } from "nanoeffects";
884
+ import { all } from "nanoeffects/effects";
885
+
886
+ const bus = createBus();
887
+
888
+ bus.on("profile/opened", async (id, ctx) => {
889
+ // all — run both requests together; if one fails, the other is aborted. child carries the signal of its own request
890
+ const { user, posts } = await all(ctx, {
891
+ user: (child) => fetchUser(id, child),
892
+ posts: (child) => fetchPosts(id, child),
893
+ });
894
+
895
+ ctx.emit("profile/loaded", { user, posts });
896
+ });
897
+ ```
898
+
899
+ ### Take the fastest answer: race
900
+
901
+ The same data lives on two servers, and you want the answer of the faster one. `race` returns the first answer and aborts the other request.
902
+
903
+ ```js
904
+ import { createBus } from "nanoeffects";
905
+ import { race } from "nanoeffects/effects";
906
+
907
+ const bus = createBus();
908
+
909
+ bus.on("user/opened", async (id, ctx) => {
910
+ // race — the first answer wins, the slower request is aborted
911
+ const result = await race(ctx, {
912
+ primary: (child) => api.getUser(id, child),
913
+ backup: (child) => api.getUserFromBackup(id, child),
914
+ });
915
+
916
+ const user = "primary" in result ? result.primary : result.backup;
917
+ ctx.emit("user/loaded", user);
918
+ });
919
+ ```
920
+
921
+ More: [`all`](effects.md#all), [`race`](effects.md#race).
922
+
923
+ ## Features and domain logic
924
+
925
+ ### Get the result of emit
926
+
927
+ `emit` of a plain event returns nothing, so to get an answer you need a second event. An action with a handler returns the result of its handler right from `emit`.
928
+
929
+ ```js
930
+ import { createBus } from "nanoeffects";
931
+ import { createAction } from "nanoeffects/actions";
932
+
933
+ const bus = createBus();
934
+
935
+ // createAction with a handler — emit runs it and returns what it returns
936
+ const double = createAction("math/double", (value) => value * 2);
937
+
938
+ const result = bus.emit(double(21)); // 42
939
+ ```
940
+
941
+ More: [Actions, jobs and modules](actions.md).
942
+
943
+ ### Group a feature with its handlers: modules
944
+
945
+ A feature has several subscriptions, and you must remember to remove each one. A module registers them together and removes them all with one `unmount`.
946
+
947
+ ```js
948
+ import { createBus } from "nanoeffects";
949
+ import { createModule, modulePlugin } from "nanoeffects/actions";
950
+
951
+ // createModule — one feature; unmount cancels everything it registered
952
+ const greeting = createModule("greeting", (m) => {
953
+ m.on("greet", (name) => console.log(`hello ${name}`));
954
+ });
955
+
956
+ const bus = createBus();
957
+ const app = bus.use(modulePlugin([greeting]));
958
+
959
+ app.unmount(greeting); // the subscription is gone
960
+ ```
961
+
962
+ More: [Modules](actions.md#modules) and [the guide](guide.md#9-mount-a-domain-as-a-module).
963
+
964
+ ### Do not leave subscriptions after a widget closes
965
+
966
+ A chat widget subscribes to the bus when it opens. If it closes without unsubscribing, the handlers stay: memory grows with every open, and they still update a widget that is gone. A module that you mount on open and unmount on close leaves nothing behind.
967
+
968
+ ```js
969
+ import { createBus } from "nanoeffects";
970
+ import { createModule, modulePlugin } from "nanoeffects/actions";
971
+
972
+ // the module owns the listeners of the widget
973
+ const chatModule = createModule("chat", (m) => {
974
+ m.on("message/received", (message) => render(message));
975
+ });
976
+
977
+ const bus = createBus();
978
+ const app = bus.use(modulePlugin());
979
+
980
+ const stop = app.mount(chatModule); // the widget is opened: the listeners are on the bus
981
+
982
+ stop(); // the widget is closed: no listeners are left
983
+ console.log(bus.get("message/received").length); // 0
984
+ ```
985
+
986
+ More: [Build a chat widget](chat-widget.md).
987
+
988
+ ## Stop work on a server
989
+
990
+ A client closes the tab, but the server goes on asking other services for it. Create an `AbortController` for the request, abort it when the connection closes, and pass its signal to `emit`: `timeout`, `retry` and the request itself stop.
991
+
992
+ ```js
993
+ import { createBus } from "nanoeffects";
994
+ import { createAction } from "nanoeffects/actions";
995
+ import { signalPlugin } from "nanoeffects/signal";
996
+
997
+ // signalPlugin — emit accepts { signal }
998
+ const bus = createBus().use(signalPlugin());
999
+
1000
+ const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
1001
+
1002
+ export async function handleRequest(request, reply) {
1003
+ const controller = new AbortController();
1004
+
1005
+ // close — the client left: abort everything started for this request
1006
+ reply.raw.on("close", () => controller.abort());
1007
+
1008
+ // the task, and the requests it made, stop with the controller
1009
+ return bus.emit(getUser(request.params.id), { signal: controller.signal });
1010
+ }
1011
+ ```
1012
+
1013
+ More: [Stop work on a server](server.md), including WebSocket and queue workers.
1014
+
1015
+ ## Debugging: what ran and why
1016
+
1017
+ An effect fired and you cannot tell which event caused it. The tracer logs every task with its parent, so you see the chain of events.
1018
+
1019
+ ```js
1020
+ import { createBus } from "nanoeffects";
1021
+ import { consoleLogger, createTracer, tracePlugin } from "nanoeffects/trace";
1022
+
1023
+ // createTracer — send every task to the console
1024
+ const trace = createTracer(consoleLogger());
1025
+
1026
+ // tracePlugin — also report every emit
1027
+ const bus = createBus({ trace }).use(tracePlugin());
1028
+
1029
+ bus.on("search", (query, ctx) => ctx.emit("results", [query]));
1030
+ bus.emit("search", "nano"); // the log shows: search · emit, then results · emit with search as the cause
1031
+ ```
1032
+
1033
+ More: [Trace tasks](tracing.md). To reload modules without a page refresh, see [Hot reload](hmr.md).
1034
+
1035
+ ## Other libraries in the project
1036
+
1037
+ - **Redux Toolkit, Zustand, MobX or Pinia.** [Connect a store](stores.md).
1038
+ - **React Query, SWR or RTK Query.** [Query libraries](query-libraries.md).
1039
+ - **RxJS.** [Use nanoeffects next to RxJS](migrating-from-rxjs.md) and [Observables](observable.md).
1040
+ - **Moving from redux-saga.** [Coming from redux-saga](migrating-from-saga.md).
1041
+ - **Moving from Effector.** [Coming from Effector](migrating-from-effector.md).
1042
+ - **Moving from mitt or nanoevents.** [Coming from mitt](migrating-from-mitt.md).
1043
+
1044
+ ## Classes and TypeScript
1045
+
1046
+ ### Use the bus in a class
1047
+
1048
+ Your logic lives in classes, and each instance must remove its subscriptions when it is no longer needed. `Emitter` gives every instance its own bus and a `dispose` that removes all of its subscriptions.
1049
+
1050
+ ```js
1051
+ import { Emitter } from "nanoeffects/oop";
1052
+
1053
+ // Emitter — every instance gets its own bus with on, once, off, emit and dispose
1054
+ class VideoService extends Emitter {
1055
+ constructor() {
1056
+ super();
1057
+ this.on("played", () => console.log("playing"));
1058
+ }
1059
+ }
1060
+
1061
+ const video = new VideoService();
1062
+ video.emit("played");
1063
+
1064
+ video.dispose(); // removes every subscription of this instance
1065
+ ```
1066
+
1067
+ More: [Use the bus from a class](oop.md).
1068
+
1069
+ ### Keep the types of the bus in many files
1070
+
1071
+ Handlers and modules are in different files, and TypeScript must know the payloads and the fields of `ctx`. See [TypeScript in many files](typescript.md).
1072
+
1073
+ ## Examples of whole apps
1074
+
1075
+ - **A form with a username check and a submit, in React.** [Build a sign-up form in React](react.md).
1076
+ - **A whole app in layers: entities, features, modules, a server.** [Build a chat](chat-example.md).
1077
+ - **A widget that listens only while it is open.** [Build a chat widget](chat-widget.md).
1078
+ - **Vue.** [Use nanoeffects in a Vue app](vue.md).
1079
+ - **Svelte.** [Use nanoeffects in a Svelte app](svelte.md).
1080
+ - **A ready project.** [Start a project from a template](starter.md).
1081
+
533
1082
  ---
534
1083
  Source: docs/starter.md
535
1084
 
536
1085
  # Start a project from a template
537
1086
 
1087
+ > **Level A.** Read first: nothing: this page makes a project for you.
1088
+
538
1089
  Use this page when you want a running project in one command instead of wiring Vite and the bus by hand. It makes a small app with a search box that waits for you to stop typing (`debounce`) and aborts the older request when a newer one starts (`latest`).
539
1090
 
540
1091
  You need Node 18 or newer.
@@ -661,17 +1212,23 @@ Source: docs/motivation.md
661
1212
 
662
1213
  # Why nanoeffects exists
663
1214
 
664
- 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).
1215
+ > **Level A.** Read first: [README](../README.md).
1216
+
1217
+ 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).
665
1218
 
666
- ## The main reason: logic outside the UI
1219
+ ## Moving logic out of the UI
667
1220
 
668
1221
  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.
669
1222
 
670
- 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.
1223
+ 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`.
1224
+
1225
+ 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).
671
1226
 
672
- ## When the constraint is a shared library
1227
+ 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.
673
1228
 
674
- 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.
1229
+ ## Micro-frontends
1230
+
1231
+ 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.
675
1232
 
676
1233
  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.
677
1234
 
@@ -684,34 +1241,23 @@ Real cases from the libraries nanoeffects was compared with:
684
1241
  | 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) |
685
1242
  | 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) |
686
1243
 
687
- 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.
688
-
689
- ## Why not the alternatives
1244
+ 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.
690
1245
 
691
- | Option | Why it did not fit |
692
- | ------ | ------------------ |
693
- | 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. |
694
- | Effector | A graph with global identity. A version or scope mismatch between the SDK and the app reaches the whole infrastructure. |
695
- | 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). |
696
- | 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. |
697
-
698
- ## What the library does instead
1246
+ ## What the library does
699
1247
 
700
1248
  - **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.
701
1249
  - **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).
702
- - **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.
1250
+ - **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.
703
1251
  - **`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.
704
1252
  - **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.
705
1253
 
706
- ## When this is not the right tool
707
-
708
- 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).
709
-
710
1254
  ---
711
1255
  Source: docs/package.md
712
1256
 
713
1257
  # Choose a package entry
714
1258
 
1259
+ > **Levels A to F.** Read first: [README](../README.md).
1260
+
715
1261
  Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
716
1262
 
717
1263
  ## Start with these three
@@ -740,7 +1286,8 @@ Each row is optional. The "when" column says what need brings you to it.
740
1286
  | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
741
1287
  | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
742
1288
  | `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 |
1289
+ | `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops) and `tracePlugin` (names for the trace) | you want to see what ran and why |
1290
+ | `nanoeffects/guard` | `loopGuardPlugin`: refuses the emit that would pass a limit of a chain | a loop must not take the app down |
744
1291
 
745
1292
  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.
746
1293
 
@@ -754,105 +1301,13 @@ The package ships plain-text files for assistants that write code with it:
754
1301
 
755
1302
  Find them in `node_modules/nanoeffects/`, or load `llms-full.txt` from a CDN such as `https://unpkg.com/nanoeffects/llms-full.txt`. All match the installed version.
756
1303
 
757
- ---
758
- Source: docs/stability.md
759
-
760
- # Know what is stable
761
-
762
- Use this page to decide what you can rely on. It says which imports are stable, what counts as a breaking change, which versions are supported, and which call forms stay.
763
-
764
- ## See which entries are stable
765
-
766
- A stable entry follows the rules below. An unstable entry can change in any minor release; every change is written in the [CHANGELOG](../CHANGELOG.md), and nothing promises a deprecation period first.
767
-
768
- | Import | Status | Note |
769
- | ------ | ------ | ---- |
770
- | `nanoeffects` | Stable | `createBus` and the types of a bus |
771
- | `nanoeffects/effects` | Stable | `retry`, `timeout`, `poll`, `take`, `delay`, `all`, `allSettled`, `race`, `backoff`, `cancellable`, `effectsPlugin` |
772
- | `nanoeffects/policies` | Stable | `latest`, `leading`, `debounce`, `throttle`, `queue`, `withPolicy` |
773
- | `nanoeffects/actions` | Stable | `createAction`, `createJob`, `createModule` and their plugins |
774
- | `nanoeffects/trace` | Stable: the contract | `Tracer`, `Trace`, `TraceEvent`, `TraceLogger`, `Phase`, `EffectName` and `tracePlugin` |
775
- | `nanoeffects/trace` | Unstable: the tools | `createTracer`, `consoleLogger`, `performanceLogger`, `createTraceRecorder`, `reduxDevtools` and their option types |
776
- | `nanoeffects/scope` | Unstable | `scopePlugin`, `effect` |
777
- | `nanoeffects/match` | Unstable | `matchPlugin` |
778
- | `nanoeffects/signal` | Unstable | `signalPlugin` |
779
- | `nanoeffects/context` | Unstable | `contextPlugin` |
780
- | `nanoeffects/oop` | Unstable | `Emitter`, `extendBus`, `On`, `Once`, `WithBus`, `disposeBus` |
781
- | `nanoeffects/observable` | Unstable | `createObserve`, `Subject` |
782
- | `nanoeffects/redux` | Unstable | `createEffectsMiddleware` |
783
- | `nanoeffects/react` | Unstable | `reactPlugin`, `usePolicy` |
784
- | `nanoeffects/svelte` | Unstable | `sveltePlugin` |
785
- | `nanoeffects/vue` | Unstable | `vuePlugin` |
786
- | `nanoeffects/dev` | Unstable | `devPlugin` |
787
-
788
- The adapters (`react`, `vue`, `svelte`, `redux`) are unstable because they follow the APIs of other libraries and have had little use. They can become stable after the library has been in use for a while.
789
-
790
- ## Know what is a breaking change
791
-
792
- For a stable entry, these changes are breaking:
793
-
794
- - A change in what a call does at run time.
795
- - A change in a type that stops code that compiled before from compiling.
796
- - A removed or renamed export.
797
-
798
- A stricter type that only finds mistakes in code that was already wrong is not breaking. It ships in a minor release with a note in the CHANGELOG.
799
-
800
- These are not covered by the promise:
801
-
802
- - Any unstable entry.
803
- - The text of the messages of `nanoeffects/dev`.
804
- - The payload details of trace events. The shape of an event (`id`, `parentId`, `rootId`, `type`, `effect`, `phase`) is covered; the contents of `payload` are not.
805
-
806
- Before version 1.0.0, a breaking change ships in a minor release under `### Breaking` in the CHANGELOG. From 1.0.0 it ships only in a major release.
807
-
808
- A deprecated export keeps working and is removed only in a major release. `RaceStarter` is deprecated: use `StarterOf` instead.
809
-
810
- ## Check the supported versions
811
-
812
- | What | Supported |
813
- | ---- | --------- |
814
- | Node.js, the library | 18, 20, 22 and 24 (a smoke test runs the built package on each) |
815
- | Node.js, the dev tools and the trace in a browser | 22 and newer |
816
- | TypeScript | 5.9 and 7. The built declaration files are type-checked with TypeScript 5.9 in CI, and the source with TypeScript 7. Older versions are not tested. |
817
- | React | 18 or newer (`nanoeffects/react`) |
818
- | Vue | 3.3 or newer (`nanoeffects/vue`) |
819
- | Svelte | 5 or newer (`nanoeffects/svelte`) |
820
-
821
- The library has no dependencies. React, Vue and Svelte are optional peer dependencies of their own entries only.
822
-
823
- ## Rely on these call forms
824
-
825
- `retry`, `timeout` and `poll` have three call forms, and all three stay:
826
-
827
- ```js
828
- import { createBus } from "nanoeffects";
829
- import { race, retry } from "nanoeffects/effects";
830
-
831
- const bus = createBus();
832
-
833
- bus.on("open", async (id, ctx) => {
834
- // with ctx: you pass the context, and the function takes the signal from it
835
- const user = await retry(ctx, () => fetchUser(id, { signal: ctx.signal }), { times: 3 });
836
-
837
- // as a starter: a group (race, all, allSettled, timeout) calls it with its child
838
- const result = await race(ctx, {
839
- user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
840
- });
841
- });
842
-
843
- // as a Task: a function of (payload, ctx), which bus.on and ctx.run call
844
- bus.on("reload", retry((id, { signal }) => fetchUser(id, { signal }), { times: 3 }));
845
- ```
846
-
847
- The forms are told apart by the arguments: a context first is the form with `ctx`; a function called with a payload and a context is the Task; the same function called by a group with one argument, its child, is the starter. The rule is part of the contract and does not depend on how many parameters your function declares.
848
-
849
- `all` and `allSettled` take options only in the form with `ctx`, `all(ctx, effects, { concurrency })`. The form without `ctx` has one argument, so a second one would look like the `ctx` form. `race` takes no `concurrency`: it ends at the first result, so a starter that waited for a free slot would never run. See [Effects](effects.md#run-several-starters-together).
850
-
851
1304
  ---
852
1305
  Source: docs/plugins.md
853
1306
 
854
1307
  # Choose the plugins of a bus
855
1308
 
1309
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
1310
+
856
1311
  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.
857
1312
 
858
1313
  ## On this page
@@ -872,6 +1327,7 @@ Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registr
872
1327
  - [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
873
1328
  - [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
874
1329
  - [Find leaks while you develop: `devPlugin`](#find-leaks-while-you-develop-devplugin)
1330
+ - [Cut a loop that runs away: `loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin)
875
1331
  - [Put them together](#put-them-together)
876
1332
  - [Use them in any order](#use-them-in-any-order)
877
1333
  - [See what is not a plugin](#see-what-is-not-a-plugin)
@@ -900,6 +1356,7 @@ Find your need in the left column. No row fits: you need no plugin.
900
1356
  | A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
901
1357
  | Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
902
1358
  | Warnings about leaks while you develop | [`devPlugin`](#find-leaks-while-you-develop-devplugin) |
1359
+ | A loop that must not take the app down | [`loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin) |
903
1360
 
904
1361
  ## Main plugins
905
1362
 
@@ -909,7 +1366,7 @@ These four cover what most apps need: shared dependencies, effects, handlers and
909
1366
  | ------ | ----------------- | ------ | ----------------- |
910
1367
  | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) | Your own fields (a store, a client, a logger) on `ctx` of every handler. | `nanoeffects` | The same bus, typed with the fields |
911
1368
  | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) | `race`, `all`, `delay` and your own effects on `ctx`, called without passing `ctx`. | `nanoeffects/effects` | The same bus, typed with the effects |
912
- | [`actionsPlugin`](#let-a-test-replace-a-handler-commandsplugin) | The handlers of actions in a registry, so a test can bind or replace one. | `nanoeffects/actions` | The registry `{ run, handle }` |
1369
+ | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) | The handlers of actions in a registry, so a test can bind or replace one. | `nanoeffects/actions` | The registry `{ run, handle }` |
913
1370
  | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) | A module: the listeners, handlers and tasks of one domain, mounted and unmounted with one call. | `nanoeffects/actions` | The app `{ mount, unmount, isMounted, dispose, actions, createModule }` |
914
1371
 
915
1372
  ### Put a store or a client on every handler: `contextPlugin`
@@ -930,7 +1387,7 @@ A client that must stop with the task, such as an `axios` instance, goes in as `
930
1387
 
931
1388
  ### Use `race` and `delay` without `ctx`: `effectsPlugin`
932
1389
 
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).
1390
+ 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#effectsplugin).
934
1391
 
935
1392
  ```js
936
1393
  import { createBus } from "nanoeffects";
@@ -969,7 +1426,7 @@ const user = await bus.emit(getUser("1")); // { id: "1" }
969
1426
 
970
1427
  ### Mount a domain as one unit: `modulePlugin`
971
1428
 
972
- A module groups the listeners, the bound handlers and the background tasks of one domain, and ends all of them when it is unmounted. It also makes [hot reload](hmr.md) work. More in [Modules](actions.md#mount-domains-with-modules).
1429
+ A module groups the listeners, the bound handlers and the background tasks of one domain, and ends all of them when it is unmounted. It also makes [hot reload](hmr.md) work. More in [Modules](actions.md#modules).
973
1430
 
974
1431
  ```js
975
1432
  import { createBus } from "nanoeffects";
@@ -996,10 +1453,11 @@ Add one of these when you meet its need. None of them is required.
996
1453
  | [`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 |
997
1454
  | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) | A React hook that emits when a value of a component changes. | `nanoeffects/react` | The same bus, with `useEmitter` |
998
1455
  | [`devPlugin`](#find-leaks-while-you-develop-devplugin) | Warnings about listeners that pile up, and messages about modules, while you develop. | `nanoeffects/dev` | A function that disconnects it |
1456
+ | [`loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin) | Refuses the emit that would pass a limit of a chain, so a loop ends. | `nanoeffects/guard` | The same bus |
999
1457
 
1000
1458
  ### Give one action its own fields: `scopePlugin`
1001
1459
 
1002
- An action with a handler, an action, a job or a module can carry its own fields and effects, and one `emit` can replace them. A test gives a fake to one call. More in [Scopes](scope.md).
1460
+ An action with a handler, an action, a job or a module can carry its own fields and effects, and one `emit` can replace them. A test gives a fake to one call. More in [Scopes](context.md#scope-fields-for-an-action-a-module-and-a-call).
1003
1461
 
1004
1462
  ```js
1005
1463
  import { createBus } from "nanoeffects";
@@ -1027,7 +1485,7 @@ import { createBus } from "nanoeffects";
1027
1485
  import { createAction } from "nanoeffects/actions";
1028
1486
  import { signalPlugin } from "nanoeffects/signal";
1029
1487
 
1030
- // signalPlugin — emit takes { signal } for an action, a job or an action
1488
+ // signalPlugin — emit takes { signal } for an action or a job
1031
1489
  const bus = createBus().use(signalPlugin());
1032
1490
 
1033
1491
  const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
@@ -1144,6 +1602,18 @@ const disconnect = bus.use(devPlugin());
1144
1602
  disconnect(); // stops the diagnostics
1145
1603
  ```
1146
1604
 
1605
+ ### Cut a loop that runs away: `loopGuardPlugin`
1606
+
1607
+ Two handlers that emit each other's events never stop, and a loop of microtasks freezes the page. The plugin follows the chain of emits and refuses the emit that would repeat one event too often or make the chain too long. It needs no tracer, so it can stay on in production. More in [Cut a loop](loop-guard.md).
1608
+
1609
+ ```js
1610
+ import { createBus } from "nanoeffects";
1611
+ import { loopGuardPlugin } from "nanoeffects/guard";
1612
+
1613
+ // loopGuardPlugin — refuses the emit that would put one event in a chain 50 times
1614
+ const bus = createBus().use(loopGuardPlugin({ maxRepeats: 50 }));
1615
+ ```
1616
+
1147
1617
  ## Put them together
1148
1618
 
1149
1619
  One bus with every plugin that does not need React, in a file of its own. The events of the app stay in other files. The plugins work in any order; the next section says what that means for modules.
@@ -1242,11 +1712,13 @@ Source: docs/policies.md
1242
1712
 
1243
1713
  # Policies
1244
1714
 
1715
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
1716
+
1245
1717
  Use this page when handlers overlap in a SPA (search-as-you-type, double submit, leave page). A **policy** wraps a handler and decides what happens then.
1246
1718
 
1247
1719
  Start with `latest`. The table and sections below are the rest of the reference.
1248
1720
 
1249
- ## Abort the previous run
1721
+ ## `latest`: abort the previous run
1250
1722
 
1251
1723
  ```js
1252
1724
  import { createBus } from "nanoeffects";
@@ -1268,7 +1740,7 @@ Pass `signal` to `fetch` (and the like) so the request cancels.
1268
1740
 
1269
1741
  ## Choose a policy
1270
1742
 
1271
- Every policy accepts `key` in its options (see [Isolate state by key](#isolate-state-by-key)).
1743
+ Every policy accepts `key` in its options (see [`key`: isolate state by key](#key-isolate-state-by-key)).
1272
1744
 
1273
1745
  | Policy | Overlapping calls |
1274
1746
  | ------ | ----------------- |
@@ -1278,54 +1750,79 @@ Every policy accepts `key` in its options (see [Isolate state by key](#isolate-s
1278
1750
  | `throttle(ms, fn, options?)` | at most one run per `ms` |
1279
1751
  | `queue(fn, options?)` | one after another, in order |
1280
1752
 
1281
- ## Call a policy without its function
1753
+ Each policy has a short entry in [Look up a policy](#look-up-a-policy).
1282
1754
 
1283
- **Problem.** Some code builds the function for you and only asks which policy to use, as [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) does. If you must pass your own function there, it is easy to pass the wrong one.
1755
+ ## Look up a policy
1284
1756
 
1285
- **When.** You pass a policy to `usePolicy`. With a bus, `withPolicy` or your own code, call the policy with its function as above.
1757
+ One entry per policy: what it does, and a link to the full recipe.
1286
1758
 
1287
- Leave the function out and the policy returns a wrapper. The wrapper takes a function and gives back the same policy as the direct call:
1759
+ ### `latest`
1288
1760
 
1289
- ```js
1290
- import { debounce } from "nanoeffects/policies";
1761
+ A newer call aborts the older one and drops its result.
1291
1762
 
1292
- const runSearch = async (query) => {
1293
- await api.search(query);
1294
- };
1763
+ Signature: `latest(fn, options?)`.
1295
1764
 
1296
- // debounce(300) — no function yet: returns a wrapper
1297
- const wrap = debounce(300);
1765
+ More: [`latest`: abort the previous run](#latest-abort-the-previous-run).
1298
1766
 
1299
- // the wrapper takes the function; same as debounce(300, runSearch)
1300
- const search = wrap(runSearch);
1301
- ```
1767
+ ### `leading`
1302
1768
 
1303
- - It works for `latest()`, `leading()`, `debounce(ms)`, `throttle(ms)` and `queue()`, with the options as the last argument: `queue({ concurrency: 2 })`, `debounce(300, { leading: true })`.
1304
- - Each time you apply a wrapper to a function, you get a new policy with its own state.
1305
- - To combine policies, nest them as before: `throttle(100, latest(fn))`.
1306
- - With `key`, annotate the payload of the key function in TypeScript: `latest({ key: (user: User) => user.id })`. Without the annotation the wrapper cannot know the payload type.
1769
+ New calls are dropped while a run is going.
1770
+
1771
+ Signature: `leading(fn, options?)`.
1772
+
1773
+ More: [Prevent a double submit](prevent-double-submit.md).
1774
+
1775
+ ### `debounce`
1776
+
1777
+ Runs after a period of quiet.
1778
+
1779
+ Signature: `debounce(ms, fn, options?)`.
1780
+
1781
+ More: [Configure debounce and throttle](#configure-debounce-and-throttle).
1782
+
1783
+ ### `throttle`
1784
+
1785
+ At most one run within `ms`.
1786
+
1787
+ Signature: `throttle(ms, fn, options?)`.
1788
+
1789
+ More: [Configure debounce and throttle](#configure-debounce-and-throttle).
1790
+
1791
+ ### `queue`
1792
+
1793
+ Builds a queue of calls: they run one after another, in order.
1794
+
1795
+ Signature: `queue(fn, options?)`.
1796
+
1797
+ More: [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue).
1798
+
1799
+ ### `key`
1800
+
1801
+ An option of every policy. It splits the state of the policy by a property, for example by id: calls with different keys do not affect each other.
1802
+
1803
+ Signature: `latest(fn, { key: (payload) => payload.id })`.
1804
+
1805
+ More: [`key`: isolate state by key](#key-isolate-state-by-key).
1806
+
1807
+ To use a policy for your own function with no bus, see [`withPolicy`: call a policy outside a bus](#withpolicy-call-a-policy-outside-a-bus).
1307
1808
 
1308
1809
  ## Configure debounce and throttle
1309
1810
 
1310
- `debounce` and `throttle` take `leading` and `trailing`. Behavior matches lodash (and patronum); tests compare random call sequences to lodash itself.
1811
+ `debounce` and `throttle` take two options, `leading` and `trailing`. They decide which call of a series runs. `leading` runs the first call of the series at once. `trailing` runs the last call at the end of the series. Both functions are built on the same algorithm as in lodash (and patronum) and behave exactly like them.
1311
1812
 
1312
- **Throttle** (`throttle(ms, fn)`):
1813
+ **Throttle** (`throttle(ms, fn)`) runs a call at once if it has been idle for at least `ms`, holds the calls that come within `ms`, and runs the last of them when the window closes. Under a steady stream it runs every `ms`.
1313
1814
 
1314
- - Default `leading: true`, `trailing: true`.
1315
- - A call when the throttle is idle (at least `ms` after the previous call and run) runs at once.
1316
- - Calls made while it waits are held; the latest runs when the window closes.
1317
- - Under a steady stream it runs every `ms`.
1318
- - `leading: false` — even the first call waits for the end of its window.
1319
- - `trailing: false` — drop calls made during a window.
1815
+ - `leading` — run the first call of a series at once. It is `true` by default. With `false`, even the first call waits for the end of its window.
1816
+ - `trailing` — run the last held call when the window closes. It is `true` by default. With `false`, calls made during a window are dropped.
1320
1817
 
1321
- **Debounce** (`debounce(ms, fn)`):
1818
+ **Debounce** (`debounce(ms, fn)`) runs the last call of a series after `ms` of quiet.
1322
1819
 
1323
- - Default `trailing: true` — run the last call after `ms` of quiet.
1324
- - `leading: true` — also run the first call of a burst at once; the trailing run happens only if more calls follow.
1325
- - `leading: true, trailing: false` — only the first call of a burst.
1820
+ - `trailing` — run the last call after `ms` of quiet. It is `true` by default.
1821
+ - `leading` — also run the first call of a series at once. It is `false` by default. The closing run then happens only if more calls follow.
1822
+ - `leading: true, trailing: false` — only the first call of a series.
1326
1823
  - Both `false` — nothing runs.
1327
1824
 
1328
- ## Isolate state by key
1825
+ ## `key`: isolate state by key
1329
1826
 
1330
1827
  `key` gives each key its own state. Calls with different keys do not affect each other. Idle keys are dropped, so keys can be unbounded (ids, urls).
1331
1828
 
@@ -1384,44 +1881,46 @@ Every call returns a promise with the result of its own run. An error rejects on
1384
1881
  - When a running call aborts, its slot is freed at once and its result is dropped.
1385
1882
  - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
1386
1883
 
1387
- ## Use a policy without a bus
1884
+ ## `withPolicy`: call a policy outside a bus
1388
1885
 
1389
- **Problem.** You want a debounced input handler or a "latest request wins" fetch, but your code has no bus. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
1886
+ **Problem.** You want a debounced input handler or a "latest request wins" fetch for your own function, but the code has no bus: a DOM handler, a click callback, a function in a service. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
1390
1887
 
1391
- **When.** One component or module needs a policy and nothing else from the library. If you already have a bus, `bus.on(event, policy)` is simpler.
1888
+ **When.** One input, button or function needs a policy and nothing else from the library. If you already have a bus, `bus.on(event, policy)` is simpler. In a React component use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders), which also keeps the function between renders.
1392
1889
 
1393
- `withPolicy` takes the result of any policy and returns a plain function. It needs no bus and no context argument, and it keeps the state of the policy between calls.
1890
+ `withPolicy` takes a policy and returns a plain function. The function needs no bus and no context argument, and it keeps the state of the policy between calls.
1394
1891
 
1395
1892
  ```js
1396
1893
  import { debounce, withPolicy } from "nanoeffects/policies";
1397
1894
 
1895
+ const input = document.querySelector("#search");
1896
+
1398
1897
  // debounce — waits for 300 ms of quiet, then runs the callback
1399
1898
  const policy = debounce(300, async (query, { signal }) => {
1400
- await api.search(query, { signal });
1899
+ const items = await api.search(query, { signal });
1900
+ render(items);
1401
1901
  });
1402
1902
 
1403
1903
  // withPolicy — a plain function: no bus, no context argument
1404
- const onChange = withPolicy(policy);
1904
+ const onInput = withPolicy(policy);
1405
1905
 
1406
- onChange("re");
1407
- onChange("redux"); // only this call runs, 300 ms after it
1906
+ input.addEventListener("input", (event) => onInput(event.target.value));
1408
1907
 
1409
- // cancel — drops a pending call and aborts a running one
1410
- onChange.cancel();
1908
+ // cancel — drops a pending call and aborts a running request, for example when the page closes
1909
+ window.addEventListener("pagehide", () => onInput.cancel());
1411
1910
  ```
1412
1911
 
1413
1912
  - The callback has the same `(payload, ctx)` shape as for a bus. `ctx.signal`, `ctx.abort` and `ctx.run` work. `ctx.emit` and `ctx.on` do nothing, because there is no bus.
1414
1913
  - Every call returns a promise with the result. A call the policy drops resolves with `undefined`.
1415
1914
  - `cancel()` aborts every call that has not finished. Calls made after it work again.
1416
- - Where `Symbol.dispose` exists, `using onChange = withPolicy(policy)` calls `cancel()` at the end of the scope.
1417
-
1418
- In a React component, use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) instead: it keeps the function between renders.
1915
+ - Where `Symbol.dispose` exists, `using onInput = withPolicy(policy)` calls `cancel()` at the end of the scope.
1419
1916
 
1420
1917
  ---
1421
1918
  Source: docs/cancel-stale-requests.md
1422
1919
 
1423
1920
  # Cancel stale requests and fix race conditions
1424
1921
 
1922
+ > **Level B.** Read first: the bus from the [README](../README.md).
1923
+
1425
1924
  Read this page if an older response can overwrite a newer one: a search box that shows the results of the previous query, a filter that flickers, a form that submits twice. It shows the cause, the fix with `latest`, and how to add a pause, a retry and a timeout. It needs only the bus from the [README](../README.md).
1426
1925
 
1427
1926
  ## See the problem
@@ -1539,7 +2038,9 @@ All policies and their options: [Policies](policies.md). The same step by step i
1539
2038
  ---
1540
2039
  Source: docs/debounce-and-abort.md
1541
2040
 
1542
- # Debounce a request and abort the previous one
2041
+ # Pause between requests
2042
+
2043
+ > **Level B.** Read first: [Policies](policies.md).
1543
2044
 
1544
2045
  Read this page if you debounce user input (a search box, an autosave, a resize handler) and you also want the request in flight to stop when a new one starts. `debounce` alone waits for a pause. It does not cancel a request that already started. This page shows the two policies together, the order to put them in, and when to use `throttle` instead.
1545
2046
 
@@ -1649,6 +2150,8 @@ Source: docs/prevent-double-submit.md
1649
2150
 
1650
2151
  # Prevent a double submit
1651
2152
 
2153
+ > **Level B.** Read first: [Policies](policies.md).
2154
+
1652
2155
  Read this page if a second click on "Pay", "Sign up" or "Save" sends the request twice. It shows how to ignore the second click while the first request runs, how to do it per item, and when to queue the calls instead.
1653
2156
 
1654
2157
  ## See the problem
@@ -1743,7 +2246,7 @@ bus.on(
1743
2246
  );
1744
2247
  ```
1745
2248
 
1746
- Backoff between tries: [Back off between retries](effects.md#back-off-between-retries).
2249
+ Backoff between tries: [`backoff`](effects.md#backoff).
1747
2250
 
1748
2251
  ## Guard a handler when you have no bus
1749
2252
 
@@ -1781,120 +2284,156 @@ Source: docs/effects.md
1781
2284
 
1782
2285
  # Effects and cancellation
1783
2286
 
1784
- Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** helper for your job; the rest is reference.
2287
+ > **Level C.** Read first: [Policies](policies.md).
2288
+
2289
+ Use this page for retry, timeouts, waiting, or parallel work — not as the first page for a small bus. Pick **one** effect for your job; the rest is reference.
1785
2290
 
1786
2291
  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)).
1787
2292
 
1788
- ## On this page
2293
+ ## Effects
2294
+
2295
+ Every effect below has its own section with its forms, options and an example. `ctx` is the context of the task.
2296
+
2297
+ - [`retry`](#retry) — repeat a call after an error.
2298
+ - [`timeout`](#timeout) — stop a call that takes too long.
2299
+ - [`poll`](#poll) — call a function at an interval.
2300
+ - [`backoff`](#backoff) — make the pause between retries grow.
2301
+ - [`delay`](#delay) — wait for a time; an abort ends the wait.
2302
+ - [`cancellable`](#cancellable) — a Promise tied to the task signal, for callbacks and timers.
2303
+ - [`onAbort`](#onabort) — run code when the task is aborted.
2304
+ - [`ctx.on` and `ctx.off`](#ctxon-and-ctxoff) — subscribe to the bus inside a task.
2305
+ - [`take`](#take) — wait for the next event inside a handler.
2306
+ - [`all`](#all) — run several calls together; one failure cancels the rest.
2307
+ - [`allSettled`](#allsettled) — wait for all of them, whatever the outcome.
2308
+ - [`race`](#race) — take the first result and cancel the rest.
2309
+ - [`effectsPlugin`](#effectsplugin) — short forms of effects: call them without `ctx`.
2310
+ - [Effects outside a task](#effects-outside-a-task) — call effects where there is no handler.
2311
+
2312
+ ## `retry`
1789
2313
 
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)
2314
+ Runs a function again after an error. Use it when a call fails now and then, for example because of the network.
1799
2315
 
1800
- ## Wrap a handler with retry and timeout
2316
+ Forms:
1801
2317
 
1802
- `retry`, `timeout`, and `poll` have two forms:
2318
+ - `retry(fn, options?)` — a wrapper of a handler: takes a function `(payload, ctx) => result` and returns one. Pass it to `on` next to policies.
2319
+ - `retry(ctx, fn, options?)` — a call inside the body of a handler.
1803
2320
 
1804
- - **Task wrapper** — take a function `(payload, ctx) => result` and return one. Pass them into `on` next to policies (no `ctx` in their arguments).
1805
- - **Call inside a body** — take `ctx` first.
2321
+ Options:
1806
2322
 
1807
- The first argument tells them apart: a function (`retry`), a number (`timeout`), or a number/function (`poll`) is a wrapper; an object is `ctx`.
2323
+ - `times` — total attempts. It is `3` by default.
2324
+ - `delay` — a number or a function `(attempt, error) => ms`. For a growing pause see [`backoff`](#backoff).
2325
+ - `shouldRetry(error, attempt)` — return `false` to stop early.
2326
+ - `fn` gets the attempt number, starting at `1`.
2327
+ - On abort, `retry` rejects with the abort reason.
1808
2328
 
1809
2329
  ```js
1810
2330
  import { createBus } from "nanoeffects";
1811
- import { poll, retry, timeout } from "nanoeffects/effects";
2331
+ import { retry } from "nanoeffects/effects";
1812
2332
  import { latest } from "nanoeffects/policies";
1813
2333
 
1814
2334
  const bus = createBus();
1815
2335
  const loadUser = (id, { signal }) => fetchUser(id, { signal });
1816
2336
 
1817
- // timeout — one try must finish within 5 s
1818
- const loadOnce = timeout(5000, loadUser);
1819
- // retry — up to 4 attempts with backoff
1820
- const loadWithRetry = retry(loadOnce, {
2337
+ // retry — a wrapper of the handler: up to 4 attempts, but not after a fatal error
2338
+ const loadWithRetry = retry(loadUser, {
1821
2339
  times: 4,
1822
- delay: (attempt) => 2 ** attempt * 100,
1823
2340
  shouldRetry: (error) => !isFatal(error),
1824
2341
  });
1825
- // latest — a newer load aborts the older retry chain
1826
- bus.on("load", latest(loadWithRetry));
1827
2342
 
1828
- // poll — call refresh every 10 s until the task aborts
1829
- bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
2343
+ // latest — a newer load aborts the older chain of attempts
2344
+ bus.on("load", latest(loadWithRetry));
1830
2345
 
1831
- // ctx.run — run a wrapped Task as a child (aborted with the handler)
2346
+ // ctx.run — run the wrapped function as a child task (aborted with the handler)
1832
2347
  bus.on("open", async (id, ctx) => {
1833
- const load = retry(timeout(5000, loadUser));
1834
- const user = await ctx.run(load, id);
2348
+ const user = await ctx.run(loadWithRetry, id);
1835
2349
  ctx.emit("opened", user);
1836
2350
  });
2351
+ ```
2352
+
2353
+ Use the wrapper for a whole handler or a whole `ctx.run`. Each attempt gets its own child context: an abort of the caller reaches it, and an abort of one attempt does not reach the caller. For one call in the middle of a body use the form with `ctx`:
2354
+
2355
+ ```js
2356
+ import { createBus } from "nanoeffects";
2357
+ import { retry } from "nanoeffects/effects";
2358
+
2359
+ const bus = createBus();
1837
2360
 
1838
- // ctx-first form — one call inside a body
1839
2361
  bus.on("load", async (id, ctx) => {
2362
+ // retry with ctx — one call inside the body
1840
2363
  const user = await retry(
1841
2364
  ctx,
1842
2365
  () => fetchUser(id, { signal: ctx.signal }),
1843
2366
  { times: 4 },
1844
2367
  );
1845
- const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
1846
- await poll(ctx, () => refresh(), 10_000);
1847
- ctx.emit("ready", { user, data });
2368
+
2369
+ ctx.emit("loaded", user);
1848
2370
  });
1849
2371
  ```
1850
2372
 
1851
- Use the wrapper for a whole handler or a whole `ctx.run`. Use the `ctx` form for one call in the middle of a body.
2373
+ ## `timeout`
2374
+
2375
+ Rejects when the work takes longer than the time you set, and aborts it. Use it when a request can hang.
2376
+
2377
+ Forms:
2378
+
2379
+ - `timeout(ms, handler)` — a wrapper of a handler.
2380
+ - `timeout(ctx, ms, fn)` — a call inside a body; `fn` gets the context of its own child task.
2381
+
2382
+ The error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`). `timeout` rejects even if the function ignores its signal.
1852
2383
 
1853
- Each attempt or call of a wrapper gets its own child context. Abort of the caller reaches it. Abort of one attempt (a timeout) does not reach the caller. The attempt number is passed only in the `ctx` form.
2384
+ ```js
2385
+ import { createBus } from "nanoeffects";
2386
+ import { timeout } from "nanoeffects/effects";
2387
+
2388
+ const bus = createBus();
2389
+ const loadUser = (id, { signal }) => fetchUser(id, { signal });
2390
+
2391
+ // timeout — a wrapper of the handler: it must finish within 5 s
2392
+ bus.on("load", timeout(5000, loadUser));
1854
2393
 
1855
- ### Options for retry, timeout, and poll
2394
+ bus.on("open", async (id, ctx) => {
2395
+ // timeout with ctx — one call inside the body; child carries the signal of the request
2396
+ const data = await timeout(ctx, 5000, (child) => fetchData(id, child));
2397
+ ctx.emit("opened", data);
2398
+ });
2399
+ ```
1856
2400
 
1857
- **`retry`**
2401
+ ## `poll`
1858
2402
 
1859
- - `times` — total attempts (default `3`).
1860
- - `delay` — number or `(attempt, error) => ms`.
1861
- - `shouldRetry(error, attempt)` — stop early.
1862
- - `fn` gets the attempt number starting at `1`.
1863
- - On abort, rejects with the abort reason.
2403
+ Calls a function again and again at an interval, until the task is aborted. Use it to refresh data every N seconds. It is like `interval` in patronum.
1864
2404
 
1865
- **`timeout`**
2405
+ Forms:
1866
2406
 
1867
- - Error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`).
1868
- - Rejects even if the starter ignores its signal.
2407
+ - `poll(ms, fn, options?)` — a wrapper of a handler.
2408
+ - `poll(ctx, fn, ms)` — a call inside a body.
1869
2409
 
1870
- **`poll`** (like patronum `interval`)
2410
+ Options:
1871
2411
 
1872
- - First call after `ms`. `leading: true` also calls at once.
1873
- - Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
1874
- - `waitForFn: true` starts each pause when the call ends — calls never overlap.
1875
- - `fn` gets the call number from `0`.
2412
+ - The first call happens after `ms`. `leading: true` also calls at once.
2413
+ - The next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call. `waitForFn: true` starts each pause when the call ends, so calls never overlap.
2414
+ - `fn` gets the call number, starting at `0`.
1876
2415
  - An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
1877
2416
 
1878
- A pause can be a function wherever a time is given. For retries with a cap and jitter, see [Back off between retries](#back-off-between-retries):
1879
-
1880
2417
  ```js
1881
2418
  import { createBus } from "nanoeffects";
1882
- import { delay, poll, retry } from "nanoeffects/effects";
2419
+ import { poll } from "nanoeffects/effects";
1883
2420
 
1884
2421
  const bus = createBus();
1885
2422
 
2423
+ // poll — a wrapper of the handler: call refresh every 10 s until the task aborts
2424
+ bus.on("watch", poll(10_000, (_, { signal }) => refresh({ signal })));
2425
+
1886
2426
  bus.on("sync", async (_, ctx) => {
1887
- retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
1888
- poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
1889
- await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
2427
+ // poll with ctx — the same inside a body
2428
+ await poll(ctx, () => refresh(), 10_000);
1890
2429
  });
1891
2430
  ```
1892
2431
 
1893
- All three are traced: the effect is a group (`retry`, `timeout`, `poll`) and each attempt or call is a `run` child.
2432
+ `retry`, `timeout` and `poll` are traced: the effect is a group, and each attempt or call is a `run` child.
1894
2433
 
1895
- ## Back off between retries
2434
+ ## `backoff`
1896
2435
 
1897
- Use this when a server or network fails under load. Retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. Make the pause grow with each attempt, and add jitter so clients spread out.
2436
+ Makes the pause between retries grow, with a cap and jitter. Use it when a server or network fails under load: retrying at once, or at the same fixed pause, sends every client back at the same moment and can keep the server down. `backoff(options)` returns a function `(attempt) => ms`.
1898
2437
 
1899
2438
  The shortest form is a `delay` function. It needs no import:
1900
2439
 
@@ -1934,49 +2473,41 @@ const loadWithRetry = retry(loadUser, {
1934
2473
  - `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
1935
2474
  - The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
1936
2475
 
1937
- ## Put selected effects on `ctx`
1938
-
1939
- If handlers call the same helpers often, put them on the task once. A handler then destructures them like `signal`, with no `ctx` argument. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
2476
+ A pause can be a function wherever a time is given:
1940
2477
 
1941
2478
  ```js
1942
2479
  import { createBus } from "nanoeffects";
1943
- import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
1944
-
1945
- // effectsPlugin — add race, all and delay to the context of every task
1946
- const effects = effectsPlugin({ race, all, delay });
1947
-
1948
- // use returns the same bus, typed with the new fields
1949
- const bus = createBus().use(effects);
2480
+ import { delay, poll, retry } from "nanoeffects/effects";
1950
2481
 
1951
- bus.on("load", async (id, { race, all, delay }) => {
1952
- // race — same as race(ctx, …)
1953
- const result = await race({
1954
- // child — the context of this starter; it has the `signal`, so the request stops with the race
1955
- data: (child) => fetchData(id, child),
1956
- // a starter gets its own child with the same bound effects, so delay(5000) stops with the race
1957
- timeout: ({ delay }) => delay(5000),
1958
- });
1959
- if ("timeout" in result) throw new Error("timed out");
2482
+ const bus = createBus();
1960
2483
 
1961
- // all — same as all(ctx, …)
1962
- const { user, posts } = await all({
1963
- user: (child) => fetchUser(id, child),
1964
- posts: (child) => fetchPosts(id, child),
1965
- });
2484
+ bus.on("sync", async (_, ctx) => {
2485
+ retry(ctx, load, { times: 5, delay: (attempt) => 2 ** attempt * 100 }); // 200, 400, 800, …
2486
+ poll(ctx, refresh, (call) => Math.min(1000 * 2 ** call, 60_000)); // slows to once a minute
2487
+ await delay(ctx, () => 200 + Math.random() * 200); // 200–400 ms
1966
2488
  });
1967
2489
  ```
1968
2490
 
1969
- - The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
1970
- - Your own function works too if its first parameter is `{ signal }` (an *effect*): `effectsPlugin({ fetchJson })` gives `ctx.fetchJson(url)`. TypeScript removes the first parameter from its type; for a generic function see [keep a generic effect generic](scope.md#keep-a-generic-effect-generic).
1971
- - Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
1972
- - Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
1973
- - Free forms keep working: `race(ctx, …)`.
2491
+ ## `delay`
1974
2492
 
1975
- ## Tie a promise to the task signal
2493
+ Waits for the given time and rejects on abort. A plain `setTimeout` would go on after the task is aborted; `delay` ends the wait at once and clears the timer. `delay(ctx, ms)`, where `ms` can be a function.
1976
2494
 
1977
- `delay(ctx, ms)` waits and rejects on abort (`ms` can be a function).
2495
+ ```js
2496
+ import { createBus } from "nanoeffects";
2497
+ import { delay } from "nanoeffects/effects";
2498
+
2499
+ const bus = createBus();
2500
+
2501
+ bus.on("saved", async (_, ctx) => {
2502
+ // delay — wait 3 s; an abort ends the wait at once and clears the timer
2503
+ await delay(ctx, 3000);
2504
+ ctx.emit("toast/hidden");
2505
+ });
2506
+ ```
2507
+
2508
+ ## `cancellable`
1978
2509
 
1979
- `cancellable(ctx, executor)` is a promise tied to the task signal. It works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
2510
+ A Promise tied to the task signal. Use it for APIs that work with callbacks, timers or listeners: the signal cannot stop them by itself, so `cancellable` rejects the Promise on abort and runs your cleanup. `cancellable(ctx, executor)` works like `new Promise`, but the executor gets `{ resolve, reject, onCancel }`.
1980
2511
 
1981
2512
  ```js
1982
2513
  import { createBus } from "nanoeffects";
@@ -1985,7 +2516,7 @@ import { latest } from "nanoeffects/policies";
1985
2516
 
1986
2517
  const bus = createBus();
1987
2518
 
1988
- // cancellable — promise tied to the task signal; onCancel for cleanups
2519
+ // cancellable — a Promise tied to the task signal; onCancel registers a cleanup
1989
2520
  // latest — a newer wait aborts the older timer
1990
2521
  bus.on(
1991
2522
  "wait",
@@ -1998,13 +2529,13 @@ bus.on(
1998
2529
  );
1999
2530
  ```
2000
2531
 
2001
- - If the task aborts before the promise settles, cleanups run (last registered first) and the promise rejects with the abort reason.
2532
+ - If the task aborts before the Promise settles, cleanups run (last registered first) and the Promise rejects with the abort reason.
2002
2533
  - If the task is already aborted, the executor does not run.
2003
- - After the promise settles, the abort listener is removed and cleanups are dropped.
2534
+ - After the Promise settles, the abort listener is removed and cleanups are dropped.
2004
2535
 
2005
- ## Run cleanup on abort
2536
+ ## `onAbort`
2006
2537
 
2007
- `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if already aborted) and returns a function that unsubscribes.
2538
+ Runs code when the task is aborted: a hook of the life of the task. A task may be aborted because the user left or a newer call replaced it, and whatever it opened (a socket, a timer, a listener) must be closed then. `onAbort(ctx, fn)` runs `fn(reason)` on abort (at once if the task is already aborted) and returns a function that unsubscribes.
2008
2539
 
2009
2540
  ```js
2010
2541
  import { createBus } from "nanoeffects";
@@ -2014,14 +2545,14 @@ const bus = createBus();
2014
2545
 
2015
2546
  bus.on("connect", (url, ctx) => {
2016
2547
  const socket = new WebSocket(url);
2017
- // onAbort — run cleanup when this task aborts
2548
+ // onAbort — close the socket when this task aborts
2018
2549
  onAbort(ctx, () => socket.close());
2019
2550
  });
2020
2551
  ```
2021
2552
 
2022
- ## Wait for the next event with `take`
2553
+ ## `ctx.on` and `ctx.off`
2023
2554
 
2024
- A task context has `on` and `off`, like the bus. A subscription made with `ctx.on` ends when the task aborts:
2555
+ Subscribes to the bus inside a task. A task context has `on` and `off`, like the bus, and a subscription made with `ctx.on` ends when the task aborts.
2025
2556
 
2026
2557
  ```js
2027
2558
  import { createBus } from "nanoeffects";
@@ -2029,15 +2560,29 @@ import { createBus } from "nanoeffects";
2029
2560
  const bus = createBus();
2030
2561
 
2031
2562
  bus.on("watch", (_, ctx) => {
2032
- ctx.on("tick", (n) => console.log(n)); // gone when this task aborts
2563
+ // gone when this task aborts
2564
+ const stop = ctx.on("tick", (n) => console.log(n));
2565
+
2566
+ // or end it sooner yourself: stop() unsubscribes
2567
+ ctx.on("watch/closed", stop);
2033
2568
  });
2034
2569
  ```
2035
2570
 
2036
- - The returned function unsubscribes sooner. After abort, `ctx.on` does nothing.
2037
- - Under `latest`, subscriptions of a superseded run end with that run.
2038
- - `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
2571
+ Be careful with it:
2572
+
2573
+ - **A task that finishes by itself is not aborted**, so its `ctx.on` subscription stays. When the work is done, unsubscribe with the function that `ctx.on` returns, or call `ctx.abort()`.
2574
+ - To wait for **one** event, use [`take`](#take): it unsubscribes by itself.
2575
+ - For listeners that live as long as a feature, use a [module](actions.md#modules): `unmount` removes all of them.
2576
+
2577
+ Also:
2578
+
2579
+ - After an abort, `ctx.on` does nothing.
2580
+ - Under `latest`, the subscriptions of a superseded run end with that run.
2581
+ - `ctx.off(event)` clears every handler of the event on the bus, not only the ones of this task.
2039
2582
 
2040
- `take(ctx, event, options?)` waits for the next `event` and resolves with its payload. `event` is the name of an event or an action creator, as for `bus.on`:
2583
+ ## `take`
2584
+
2585
+ Waits for the next event of the bus inside a handler and resolves with its payload. `take(ctx, event, options?)`. The `event` is the name of an event or an action creator, as for `bus.on`.
2041
2586
 
2042
2587
  ```js
2043
2588
  import { createBus } from "nanoeffects";
@@ -2059,22 +2604,65 @@ bus.on("login", async (user, ctx) => {
2059
2604
  const id = await timeout(
2060
2605
  ctx,
2061
2606
  5000,
2062
- take("saved", { predicate: (savedId) => savedId === draft.id }), // take without ctx: a starter
2607
+ take("saved", { predicate: (savedId) => savedId === draft.id }), // take without ctx — a ready-made function for the group
2063
2608
  );
2064
2609
  });
2065
2610
  ```
2066
2611
 
2067
- - Sees only events emitted after the call, takes one, and unsubscribes.
2612
+ - It sees only events emitted after the call, takes one, and unsubscribes.
2068
2613
  - With an action creator the result has the payload type of the creator. A creator made without a payload type (`createAction("saved")`) takes it from the event map of the bus.
2069
2614
  - `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
2070
- - `options.signal` stops the wait. Inside `race`, `all` or `timeout` you do not need it: the group already stops the starter, so write `take("saved")` without options.
2071
- - On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
2615
+ - `options.signal` stops the wait. Inside `race`, `all` or `timeout` you do not need it: the group already stops the wait, so write `take("saved")` without options.
2616
+ - On abort of the task or of `options.signal`, it unsubscribes and rejects with the abort reason.
2617
+
2618
+ ## `all`
2619
+
2620
+ Runs several calls together; one failure cancels the rest. Use it to load several things at once. `all(ctx, effects)` takes an object or an array of functions, and the result keeps that shape.
2621
+
2622
+ ```js
2623
+ import { createBus } from "nanoeffects";
2624
+ import { all } from "nanoeffects/effects";
2625
+
2626
+ const bus = createBus();
2627
+
2628
+ bus.on("open", async (id, ctx) => {
2629
+ // all — run both requests together; one failure cancels the other. child is the context of its own request
2630
+ const { user, posts } = await all(ctx, {
2631
+ user: (child) => fetchUser(id, child),
2632
+ posts: (child) => fetchPosts(id, child),
2633
+ });
2634
+
2635
+ ctx.emit("opened", { user, posts });
2636
+ });
2637
+ ```
2638
+
2639
+ Every function of the group gets `child`, the context of its own child task. Pass `child` as the first argument to any effect, as a handler passes `ctx`: when the group ends or fails, `child` is aborted and stops exactly this effect. If your API takes the signal in its options, `child` has a `signal`, so pass it as the options: `(child) => fetchUser(id, child)` (`fetch` reads only `signal` from it). The same holds for `allSettled`, `race` and `timeout`.
2640
+
2641
+ **Limit how many run at once.** `all` and `allSettled` start everything together. Over a long list that is a burst of requests. Pass `{ concurrency }` as the third argument and at most that many run at a time; the next one starts when a running one settles:
2072
2642
 
2073
- ## Run several starters together
2643
+ ```js
2644
+ import { createBus } from "nanoeffects";
2645
+ import { all, retry } from "nanoeffects/effects";
2646
+
2647
+ const bus = createBus();
2648
+
2649
+ bus.on("sync", async (ids, ctx) => {
2650
+ // all with concurrency — 5 loads at a time, results in the order of ids
2651
+ const users = await all(
2652
+ ctx,
2653
+ ids.map((id) => retry(() => loadUser(id), { times: 2 })),
2654
+ { concurrency: 5 },
2655
+ );
2656
+
2657
+ render(users);
2658
+ });
2659
+ ```
2660
+
2661
+ The result has the same order and shape as without the option. If one call of `all` fails, the running ones are cancelled and the waiting ones are never started. `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 call that waited for a free slot would never run.
2074
2662
 
2075
- `all(ctx, effects)` runs starters together. One failure cancels the rest. Shape of the input (object or array) is preserved in the result.
2663
+ ## `allSettled`
2076
2664
 
2077
- `allSettled(ctx, effects)` waits for every starter and never fails because of one. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a starter fails. Only an abort of `ctx` rejects it.
2665
+ Waits for every call and never fails because of one. Use it when a partial answer is better than none. `allSettled(ctx, effects)`. Each result is `{ status: "fulfilled", value }` or `{ status: "rejected", reason }`. Nothing is cancelled when a call fails; only an abort of `ctx` rejects it.
2078
2666
 
2079
2667
  ```js
2080
2668
  import { createBus } from "nanoeffects";
@@ -2083,6 +2671,7 @@ import { allSettled } from "nanoeffects/effects";
2083
2671
  const bus = createBus();
2084
2672
 
2085
2673
  bus.on("open", async (id, ctx) => {
2674
+ // allSettled — wait for both, whatever the outcome
2086
2675
  const { user, posts } = await allSettled(ctx, {
2087
2676
  user: (child) => fetchUser(id, child),
2088
2677
  posts: (child) => fetchPosts(id, child),
@@ -2094,7 +2683,9 @@ bus.on("open", async (id, ctx) => {
2094
2683
  });
2095
2684
  ```
2096
2685
 
2097
- `race(ctx, effects)` settles with the first starter that finishes and cancels the others.
2686
+ ## `race`
2687
+
2688
+ Settles with the first call that finishes and cancels the others. Use it to take the fastest answer or to cut a wait short. `race(ctx, effects)`.
2098
2689
 
2099
2690
  ```js
2100
2691
  import { createBus } from "nanoeffects";
@@ -2103,9 +2694,9 @@ import { delay, race, take } from "nanoeffects/effects";
2103
2694
  const bus = createBus();
2104
2695
 
2105
2696
  bus.on("save", async (draft, ctx) => {
2106
- // race — the first starter to finish wins, the other is cancelled
2697
+ // race — the first to finish wins, the other is cancelled
2107
2698
  const result = await race(ctx, {
2108
- waited: delay(1000), // delay without ctx — a starter: race gives it its own child
2699
+ waited: delay(1000), // delay without ctx — a ready-made function for the group: race gives it its own child
2109
2700
  cancelled: take("cancel"), // take without ctx — the same, waits for the next "cancel"
2110
2701
  });
2111
2702
 
@@ -2115,9 +2706,9 @@ bus.on("save", async (draft, ctx) => {
2115
2706
  });
2116
2707
  ```
2117
2708
 
2118
- **`delay` and `take` without `ctx` are ready-made starters.** In a group, `delay(1000)` and `take("cancel")` do what `(child) => delay(child, 1000)` and `(child) => take(child, "cancel")` do. The group calls them with the child, so the wait stops with the group. Use this form inside `race`, `all`, `allSettled` and `timeout`; use `delay(ctx, 1000)` in plain handler code. A starter waits only when a group calls it: do not `await delay(1000)` on its own, it is a function, not a promise. Write `take("cancel")` in the group, as above; a starter stored in a variable outside it does not know the events of your bus, and you name them yourself in TypeScript (`const cancelled: StarterOf<Events> = take("cancel")`; `StarterOf` is exported by `nanoeffects/effects`).
2709
+ **`delay` and `take` without `ctx` are ready-made functions for a group.** In a group, `delay(1000)` and `take("cancel")` do what `(child) => delay(child, 1000)` and `(child) => take(child, "cancel")` do: the group calls them with the child, so the wait stops with the group. Use this form inside `race`, `all`, `allSettled` and `timeout`; use `delay(ctx, 1000)` in plain handler code. They wait only when a group calls them: `await delay(1000)` on its own does not wait, because it is a function, not a Promise. In TypeScript, one kept in a variable outside a group does not know the events of your bus; name them yourself: `const cancelled: StarterOf<Events> = take("cancel")` (`StarterOf` is exported by `nanoeffects/effects`).
2119
2710
 
2120
- The wrapper form of `retry`, `timeout` and `poll` is a starter too: a group calls it with the child, and the function you wrap gets `undefined` as its payload and the child as its context. Write the data it needs into a closure:
2711
+ The wrapper form of `retry`, `timeout` and `poll` works in a group too: the group calls it with the child, and the function you wrap gets `undefined` as its payload and the child as its context. Write the data it needs into a closure:
2121
2712
 
2122
2713
  ```js
2123
2714
  import { createBus } from "nanoeffects";
@@ -2127,7 +2718,7 @@ const bus = createBus();
2127
2718
 
2128
2719
  bus.on("open", async (id, ctx) => {
2129
2720
  const result = await race(ctx, {
2130
- // retry without ctx — a starter: up to 3 attempts, each with the signal of the child
2721
+ // retry without ctx — up to 3 attempts, each with the signal of the child
2131
2722
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
2132
2723
  // delay without ctx — the loser is cancelled when the other one finishes
2133
2724
  waited: delay(5000),
@@ -2139,9 +2730,9 @@ bus.on("open", async (id, ctx) => {
2139
2730
  });
2140
2731
  ```
2141
2732
 
2142
- The same call with a payload and a context, `(payload, ctx)`, is the Task that `bus.on` and `ctx.run` use, and it does not change. `retry`, `timeout` and `poll` therefore have three call forms: with `ctx`, as a Task, and as a starter. All three stay; [Stability](stability.md#rely-on-these-call-forms) says how they are told apart.
2733
+ So `retry`, `timeout` and `poll` have three call forms: with `ctx`, as a Task for `bus.on` and `ctx.run`, and inside a group. The arguments tell them apart: a context first is the `ctx` form; a function called with a payload and a context is a Task; the same function called by a group with one argument, its child, is the group form. This does not depend on how many parameters your function declares.
2143
2734
 
2144
- **`all`, `allSettled` and `race` without `ctx` are starters too**, so a group nests in another group with no wrapper. Load two things, but give up after five seconds:
2735
+ **`all`, `allSettled` and `race` without `ctx` also work in a group**, so a group nests in another group with no wrapper. Load two things, but give up after five seconds:
2145
2736
 
2146
2737
  ```js
2147
2738
  import { createBus } from "nanoeffects";
@@ -2151,7 +2742,7 @@ const bus = createBus();
2151
2742
 
2152
2743
  bus.on("open", async (id, ctx) => {
2153
2744
  const result = await race(ctx, {
2154
- // all without ctx — a starter: loads both, each with up to 3 attempts
2745
+ // all without ctx — loads both, each with up to 3 attempts
2155
2746
  loaded: all({
2156
2747
  user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
2157
2748
  posts: retry((_, { signal }) => fetchPosts(id, { signal }), { times: 3 }),
@@ -2166,46 +2757,77 @@ bus.on("open", async (id, ctx) => {
2166
2757
  });
2167
2758
  ```
2168
2759
 
2169
- One argument is the short form and two arguments are the form with `ctx`: `all(effects)` returns a starter, `all(ctx, effects)` runs the group. The inner group stops with the outer one and cancels its own starters. Passing only a context, `all(ctx)`, is a mistake and throws a `TypeError` at once. A group starter written in a group needs no type. One kept in a variable outside a group does not know its bus: in TypeScript, annotate it with `StarterOf<Events>`, or `StarterOf<Events, Result>` when you need its value.
2760
+ One argument is the short form and two arguments are the form with `ctx`: `all(effects)` returns a function for a group, `all(ctx, effects)` runs the group. The inner group stops with the outer one and cancels its own calls. Passing only a context, `all(ctx)`, is a mistake and throws a `TypeError` at once. The form without `ctx` takes no options, because a second argument would look like the form with `ctx`; write `(child) => all(child, effects, { concurrency: 5 })` there. In TypeScript, a group kept in a variable outside a group needs a type: `StarterOf<Events>`, or `StarterOf<Events, Result>` when you need its value.
2761
+
2762
+ ## `effectsPlugin`
2170
2763
 
2171
- **Limit how many run at once.** `all` and `allSettled` start every starter together. Over a long list that is a burst of requests. Pass `{ concurrency }` as the third argument and at most that many run at a time; the next one starts when a running one settles:
2764
+ Short forms of effects: `effectsPlugin(effects)` puts chosen effects on `ctx`, so a handler calls them without passing `ctx` around. Use it when many handlers call the same helpers, to cut the boilerplate. A handler destructures them like `signal`. Only the effects you pass are imported, so the rest stay out of the bundle ([package entries](package.md)).
2172
2765
 
2173
2766
  ```js
2174
2767
  import { createBus } from "nanoeffects";
2175
- import { all, retry } from "nanoeffects/effects";
2768
+ import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
2176
2769
 
2177
- const bus = createBus();
2770
+ // effectsPlugin — add race, all and delay to the context of every task
2771
+ const effects = effectsPlugin({ race, all, delay });
2178
2772
 
2179
- bus.on("sync", async (ids, ctx) => {
2180
- // all with concurrency — 5 loads at a time, results in the order of ids
2181
- const users = await all(
2182
- ctx,
2183
- ids.map((id) => retry(() => loadUser(id), { times: 2 })),
2184
- { concurrency: 5 },
2185
- );
2773
+ // use returns the same bus, typed with the new fields
2774
+ const bus = createBus().use(effects);
2186
2775
 
2187
- render(users);
2776
+ bus.on("load", async (id, { race, all, delay }) => {
2777
+ // race — same as race(ctx, …)
2778
+ const result = await race({
2779
+ data: (child) => fetchData(id, child),
2780
+ // a group function gets its own child with the same bound effects, so delay(5000) stops with the race
2781
+ timeout: ({ delay }) => delay(5000),
2782
+ });
2783
+ if ("timeout" in result) throw new Error("timed out");
2784
+
2785
+ // all — same as all(ctx, …)
2786
+ const { user, posts } = await all({
2787
+ user: (child) => fetchUser(id, child),
2788
+ posts: (child) => fetchPosts(id, child),
2789
+ });
2188
2790
  });
2189
2791
  ```
2190
2792
 
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.
2793
+ - The key is the name the effect gets on `ctx`. `race`, `all`, `allSettled`, `take` and `delay` are typed by hand.
2794
+ - Your own function works too if its first parameter is `{ signal }` (an *effect*): `effectsPlugin({ fetchJson })` gives `ctx.fetchJson(url)`. TypeScript removes the first parameter from its type; for a generic function see [keep a generic effect generic](context.md#keep-a-generic-effect-generic).
2795
+ - Leave `retry`, `timeout` and `poll` out: bound to the task they would look like their handler wrappers.
2796
+ - Fields from `createBus({ context })` stay. TypeScript rejects an effect whose name is already a context field of the bus, including one added by an earlier `effectsPlugin`.
2797
+ - The free forms keep working: `race(ctx, …)`.
2798
+
2799
+ ## Effects outside a task
2192
2800
 
2801
+ Effects take `ctx` first. Inside a handler it is the `ctx` you already have. Outside a task (in a plain function, a script or a test) there is no `ctx`: pass an object with a `signal`, or `{}` if you never abort.
2193
2802
 
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`.
2803
+ ```js
2804
+ import { delay, retry } from "nanoeffects/effects";
2805
+
2806
+ const controller = new AbortController();
2195
2807
 
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.
2808
+ // outside a task there is no ctx: an object with a signal is enough
2809
+ const context = { signal: controller.signal };
2197
2810
 
2198
- ## Pass `ctx` outside a task
2811
+ // the effects take it as ctx; fetchUser gets the same signal, so the request itself stops too
2812
+ const user = await retry(
2813
+ context,
2814
+ () => fetchUser("1", { signal: controller.signal }),
2815
+ { times: 3 },
2816
+ );
2199
2817
 
2200
- Effects that wait inside a body take `ctx` first: `delay`, `all`, `allSettled`, `race`, `cancellable`, `onAbort`, `take`, and the `ctx` forms of `retry` / `timeout` / `poll`. Outside a task, pass `{}` or any object with a `signal`. The one exception is `delay(ms)` and `take(event)` as starters of a group, which take no `ctx` (see above).
2818
+ await delay(context, 500);
2201
2819
 
2202
- For `fetch` and other APIs that take a signal, pass `ctx.signal`. A timeout without `race`: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])`.
2820
+ // stops the pending retries and waits
2821
+ controller.abort();
2822
+ ```
2203
2823
 
2204
2824
  ---
2205
2825
  Source: docs/errors.md
2206
2826
 
2207
2827
  # Handle errors and unheard events
2208
2828
 
2829
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
2830
+
2209
2831
  Defaults match a small bus. Set these when you want isolation or typo warnings.
2210
2832
 
2211
2833
  ## Isolate failing handlers
@@ -2227,9 +2849,9 @@ const bus = createBus({
2227
2849
 
2228
2850
  ## Warn when nobody listens
2229
2851
 
2230
- A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for actions, which always have a handler to run.
2852
+ A typo in an event name (`emit("serch")`) does nothing and says nothing. `onUnhandled` runs when an event is emitted and nobody listens (a listener of every event, `"*"`, counts). It is not called for an action that has a handler to run. An action without a handler is only an event, so it is called for it.
2231
2853
 
2232
- Use it as a warning in development:
2854
+ Use it as a warning in development (`import.meta.env.DEV` is the Vite flag; use your own check in another bundler):
2233
2855
 
2234
2856
  ```js
2235
2857
  import { createBus } from "nanoeffects";
@@ -2246,7 +2868,24 @@ Source: docs/actions.md
2246
2868
 
2247
2869
  # Actions, jobs and modules
2248
2870
 
2249
- Use this page when **domain logic lives on the bus**, off the UI: the UI emits and listens; handlers and modules own the work.
2871
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 6.
2872
+
2873
+ When a project has a lot of business logic, you need to split it. One way is to split it by domains, or entities. In a shop, the domains could be products and product categories. Then the code that belongs to the actions of one domain goes into one file (`product.js`), its handlers into another, and the module that mounts them into a third. A domain can then be mounted, replaced or unmounted without touching the others. For the shop the files could look like this:
2874
+
2875
+ ```text
2876
+ src/
2877
+ bus.js
2878
+ product/
2879
+ product.js
2880
+ product-handlers.js
2881
+ product-module.js
2882
+ category/
2883
+ category.js
2884
+ category-handlers.js
2885
+ category-module.js
2886
+ ```
2887
+
2888
+ The examples below show the content of such files.
2250
2889
 
2251
2890
  If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until you need a result from `emit` or a mountable domain.
2252
2891
 
@@ -2256,37 +2895,37 @@ A chat with two entities and three features, jobs under policies and effects, an
2256
2895
 
2257
2896
  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)).
2258
2897
 
2259
- ## Pick the word: event, action, job, module
2898
+ ## Action, job and module
2260
2899
 
2261
- Four names, one idea each. Take the first row that fits.
2900
+ Four forms, one idea each. Take the first row that fits.
2262
2901
 
2263
- | Word | What it is | Use it when |
2902
+ | Form | What it is | Use it when |
2264
2903
  | ---- | ---------- | ----------- |
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. |
2904
+ | `createAction(type)` | An action with no handler: a name and a payload. | The UI or a handler tells that something happened. |
2905
+ | `createAction(type, handler)` | An action with a handler: `emit` runs the handler and returns its result. | You want the result of the handler from `emit`. |
2906
+ | `createJob(type, handler)` | An action with a handler that returns its result as the actions `success` and `fail`, and also sends `pending` and `cancel`. | You want the result of the work of a handler and, if you need it, to subscribe to the state of that work. |
2907
+ | `createModule(name, setup)` | A group of actions and jobs with their handlers, mounted and unmounted with one call. | One domain must start and stop as a unit. |
2269
2908
 
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.
2909
+ Start with actions. Add a handler when you need a result, a job when you need a status, a module when you need a unit.
2271
2910
 
2272
2911
  ## On this page
2273
2912
 
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)
2913
+ - [Action, job and module](#action-job-and-module)
2914
+ - [Get the result of `emit`](#get-the-result-of-emit)
2915
+ - [Actions, handlers and jobs](#actions-handlers-and-jobs)
2277
2916
  - [Actions](#actions)
2278
2917
  - [Bind a handler later](#bind-a-handler-later)
2279
- - [Say what a bound handler returns](#say-what-a-bound-handler-returns)
2918
+ - [The result type of a bound handler](#the-result-type-of-a-bound-handler)
2280
2919
  - [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)
2920
+ - [The state of a job](#the-state-of-a-job)
2921
+ - [Modules](#modules)
2922
+ - [Extend the context of actions and modules](#extend-the-context-of-actions-and-modules)
2284
2923
 
2285
- ## Return a value from `emit` with a handler
2924
+ ## Get the result of `emit`
2286
2925
 
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.
2926
+ When you want an answer from a handler at the `emit` call, give `createAction` the handler. A handler is a function `(payload, ctx) => result` that runs when the action is emitted. `emit` runs it and returns its result. There is one creator for an action with and without a handler; the handler is the only difference.
2288
2927
 
2289
- The bus lives in one file and its events in another, so any module can import both without a cycle.
2928
+ The bus lives in one file and its actions in another, so any module can import both without a cycle.
2290
2929
 
2291
2930
  ```js
2292
2931
  // bus.js
@@ -2311,7 +2950,7 @@ import { double } from "./math";
2311
2950
  bus.emit(double(21)); // 42
2312
2951
  ```
2313
2952
 
2314
- ## Describe events, handlers and jobs
2953
+ ## Actions, handlers and jobs
2315
2954
 
2316
2955
  `nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
2317
2956
 
@@ -2346,7 +2985,7 @@ These are the events of the user domain, in a file of their own (`user.js`). The
2346
2985
  - `userLoaded.match(action)` — narrows an action
2347
2986
  - `createAction(type, handler)` — a function as the second argument is the handler; `bus.emit(action(payload))` tells the listeners, runs the handler as a task and returns its result
2348
2987
  - `createAction(type, { prepare })` — build the payload from several arguments
2349
- - `createAction(type, { handle, prepare, ...scope })` — everything in one object: `handle` and `prepare` are taken out, and every other field is the [scope](scope.md)
2988
+ - `createAction(type, { handle, prepare, ...scope })` — everything in one object: `handle` and `prepare` are taken out, and every other field is the [scope](context.md#scope-fields-for-an-action-a-module-and-a-call)
2350
2989
  - Works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
2351
2990
 
2352
2991
  A scope goes after a handler as the last argument, `createAction(type, handler, scope)`; `prepare` with a handler goes in that last object: `createAction(type, handler, { prepare, ...scope })`.
@@ -2396,7 +3035,7 @@ A handler bound in the registry makes `bus.emit(sendMail(mail))` run it and retu
2396
3035
 
2397
3036
  The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the handler that the action carries. `bus.use(actionsPlugin())` sets `bus.actions`. A second `actionsPlugin` binds its list in that registry and returns it. There is no `actions` option in `createBus`; assign a custom runner with `bus.actions = { run }`, and add `bound(type)` to it to answer for actions that have no handler of their own.
2398
3037
 
2399
- ### Say what a bound handler returns
3038
+ ### The result type of a bound handler
2400
3039
 
2401
3040
  TypeScript cannot see which handler the registry has, so the type of `emit` for an action without a handler is `void`. Tell it what the bound handler returns with `Call<Payload, Result>`. This is a promise of yours: nothing checks it against the handler.
2402
3041
 
@@ -2440,7 +3079,7 @@ On a bus without an event map you can say it where you call: `const id = bus.emi
2440
3079
  - Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
2441
3080
  - 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.
2442
3081
 
2443
- ## Show a loading state with a job
3082
+ ## The state of a job
2444
3083
 
2445
3084
  **Problem.** A spinner needs a flag that is `true` while a request runs. Setting it by hand in the handler (`loading = true`, then `try` / `finally`) breaks when the task is aborted: `ctx.emit` does nothing after an abort, so a flag that is cleared through the bus stays `true`.
2446
3085
 
@@ -2513,7 +3152,7 @@ bus.emit(loadUser("1"));
2513
3152
 
2514
3153
  A flag is exact when calls do not overlap, or when `latest` replaces them. If several runs can go at once (`queue` with `concurrency` above 1, or a job without a policy called many times), keep a counter: add one on `pending` and take one off on `success`, `fail` and `cancel`. A policy that you write yourself, or that you hide in your own function, does not report to the job; `pending` is then sent at the call, as for a job with no policy.
2515
3154
 
2516
- ## Mount domains with modules
3155
+ ## Modules
2517
3156
 
2518
3157
  **Problem.** A domain needs several subscriptions and handler bindings. With plain `bus.on` / `actions.handle` you must remember every unsubscribe.
2519
3158
 
@@ -2606,9 +3245,9 @@ If the `setup` of one module throws, the modules of that call that were already
2606
3245
  - `spawn` — background task, aborted on unmount.
2607
3246
  - An action that carries a handler keeps answering after unmount (`use` only tracks registration). Use `handle` for a handler that must live only as long as the module.
2608
3247
 
2609
- ## Give an action or a module its own fields
3248
+ ## Extend the context of actions and modules
2610
3249
 
2611
- An action with a handler can need its own client, a module its own logger. Pass a scope, a plain object whose keys go on `ctx` (`$effects` binds functions to the task), as the last argument of `createAction`, `createAction`, `createModule` and `createJob`, and turn it on with `scopePlugin`:
3250
+ An action with a handler can need its own client, a module its own logger. Pass a scope, a plain object whose keys go on `ctx` (`$effects` binds functions to the task), as the last argument of `createAction`, `createModule` and `createJob`, and turn it on with `scopePlugin`:
2612
3251
 
2613
3252
  ```js
2614
3253
  // bus.js
@@ -2640,13 +3279,15 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
2640
3279
  bus.emit(getUser("1"), { api: fakeApi });
2641
3280
  ```
2642
3281
 
2643
- The handler gets the fields typed. Without `scopePlugin` the scope is ignored. The full picture, with jobs, actions, modules and which value wins, is in [scopes](scope.md).
3282
+ The handler gets the fields typed. Without `scopePlugin` the scope is ignored. The full picture, with jobs, actions, modules and which value wins, is in [scopes](context.md#scope-fields-for-an-action-a-module-and-a-call).
2644
3283
 
2645
3284
  ---
2646
3285
  Source: docs/stores.md
2647
3286
 
2648
3287
  # Connect Redux, Zustand, MobX, or Pinia
2649
3288
 
3289
+ > **Level E.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
3290
+
2650
3291
  Use this page when you **already have a store** and want async work with cancellation and policies next to it. It is not a reason to pick Redux (or any store).
2651
3292
 
2652
3293
  If you do not have a store yet, use `createBus` from the [Guide](guide.md).
@@ -3322,6 +3963,8 @@ Source: docs/query-libraries.md
3322
3963
 
3323
3964
  # Use nanoeffects with React Query, SWR and RTK Query
3324
3965
 
3966
+ > **Level E.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
3967
+
3325
3968
  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.
3326
3969
 
3327
3970
  You need a React project, `nanoeffects`, and one of the libraries. `api` below is your own client: replace it with your requests.
@@ -3448,7 +4091,7 @@ import { createAction } from "nanoeffects/actions";
3448
4091
  export const userLoaded = createAction("user/loaded");
3449
4092
  ```
3450
4093
 
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).
4094
+ 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#the-state-of-a-job).
3452
4095
 
3453
4096
  ## Call it from RTK Query
3454
4097
 
@@ -3720,6 +4363,8 @@ Source: docs/server.md
3720
4363
 
3721
4364
  # Stop work on a server when the client leaves
3722
4365
 
4366
+ > **Level C.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4367
+
3723
4368
  Read this page if you run a Node server (the examples use [Fastify](https://fastify.dev)) and want the work for a request, a socket or a queued job to stop when nobody needs it. It shows one pattern in three places: a request, a WebSocket and a worker.
3724
4369
 
3725
4370
  You need Node 18 or newer, `fastify` and `nanoeffects`. `api` below is your own client for another service: replace it with your requests.
@@ -3926,31 +4571,35 @@ Source: docs/react.md
3926
4571
 
3927
4572
  # Build a sign-up form in React
3928
4573
 
3929
- Use this page when a React screen starts requests on user input and you need them not to race: a username check while typing, a submit button, a flaky network. It builds one registration form. The bus lives outside React; the component only subscribes to it.
4574
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4575
+
4576
+ The task is a registration form. While the user types a name, the form asks the backend whether it is free, and the "Sign up" button submits the form. That makes the requests race: the answer to an old input can arrive after the answer to a new one and show a wrong result. This page shows how to solve it with nanoeffects. The bus lives outside React; the component only subscribes to it.
4577
+
4578
+ Steps 1 and 2 are required, step 3 is not. The last two sections are extra.
3930
4579
 
3931
4580
  You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
3932
4581
 
3933
4582
  ## On this page
3934
4583
 
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)
4584
+ - [Problems of the form without a bus](#problems-of-the-form-without-a-bus)
4585
+ - [1. The bus and the handlers](#1-the-bus-and-the-handlers)
4586
+ - [2. The form component](#2-the-form-component)
4587
+ - [3. Stop requests when the form closes](#3-stop-requests-when-the-form-closes)
3939
4588
  - [Show the last value of an event as state](#show-the-last-value-of-an-event-as-state)
3940
4589
  - [Keep a debounced handler between renders](#keep-a-debounced-handler-between-renders)
3941
4590
  - [Where to go next](#where-to-go-next)
3942
4591
 
3943
- ## See what goes wrong without a bus
4592
+ ## Problems of the form without a bus
3944
4593
 
3945
4594
  A registration form has three problems that `useEffect` and `fetch` leave to you:
3946
4595
 
3947
- - **A stale answer.** The user types `ann`, then `anna`. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
4596
+ - **A race in the name check.** The user types `ann`, then `anna`, and every input sends a "is the name free" request. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
3948
4597
  - **A double submit.** A second click on "Sign up" sends a second request while the first one is running.
3949
4598
  - **A flaky network.** One failed request should be retried, but a "name is taken" answer should not.
3950
4599
 
3951
4600
  Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one place, outside the component.
3952
4601
 
3953
- ## Wire the bus once
4602
+ ## 1. The bus and the handlers
3954
4603
 
3955
4604
  Keep the bus in a file of its own, and the handlers of the form in another. Create the bus once, outside any component: a bus created in the body of a component would be a new bus, without subscribers, on every render.
3956
4605
 
@@ -4007,7 +4656,7 @@ bus.on(
4007
4656
 
4008
4657
  The handlers know nothing about React. A test can emit `username/typed` and read `username/checked` without rendering anything: the same wiring is tested in [`examples/registration-form.ts`](../examples/registration-form.ts) and `src/tests/registration-form.test.ts`.
4009
4658
 
4010
- ## Connect the form
4659
+ ## 2. The form component
4011
4660
 
4012
4661
  <!-- docs-check: continue -->
4013
4662
 
@@ -4078,11 +4727,11 @@ The `disabled` button is only for the look. The form stays correct without it: `
4078
4727
 
4079
4728
  In development, React StrictMode runs the effect twice (subscribe, unsubscribe, subscribe). That is safe here, because the cleanup removes exactly what the effect added.
4080
4729
 
4081
- ## Stop running work when the form closes
4730
+ ## 3. Stop requests when the form closes
4082
4731
 
4083
4732
  <!-- docs-check: continue -->
4084
4733
 
4085
- Unsubscribing stops the component from hearing results, but a request that has started keeps running. To cancel it, the component emits `form/closed` when it unmounts, and each handler that should stop listens for it with `ctx.on`:
4734
+ This step is optional. Unsubscribing stops the component from hearing results, but a request that has started keeps running. To cancel it, the component emits `form/closed` when it unmounts, and each handler that should stop listens for it with `ctx.on`:
4086
4735
 
4087
4736
  ```js
4088
4737
  import { latest } from "nanoeffects/policies";
@@ -4204,12 +4853,37 @@ export const Search = ({ wait }) => {
4204
4853
  - Write the policy without its callback: `debounce(300)`, not `debounce(300, callback)`. `usePolicy` puts the callback in itself, always the one of the latest render.
4205
4854
  - To combine policies, write a function of the task: `(task) => throttle(100, latest(task))`. Here `task` is the callback that `usePolicy` keeps current; pass it, not your own function.
4206
4855
  - `deps` stay your job: `debounce(wait)` is made again on every render, but only the first one (and the one after `deps` change) is used.
4207
- - Without a React component, use [`withPolicy`](policies.md#use-a-policy-without-a-bus).
4856
+ - Without a React component, use [`withPolicy`](policies.md#withpolicy-call-a-policy-outside-a-bus).
4857
+
4858
+ ### Write a policy without its function
4859
+
4860
+ **Problem.** `usePolicy` puts the callback of the latest render into the policy itself, so it needs a policy that has no function yet. If you passed your own function there, it would be easy to pass the wrong one.
4861
+
4862
+ Leave the function out and the policy returns a wrapper. The wrapper takes a function and gives back the same policy as the direct call:
4863
+
4864
+ ```js
4865
+ import { debounce } from "nanoeffects/policies";
4866
+
4867
+ const runSearch = async (query) => {
4868
+ await api.search(query);
4869
+ };
4870
+
4871
+ // debounce(300) — no function yet: returns a wrapper
4872
+ const wrap = debounce(300);
4873
+
4874
+ // the wrapper takes the function; same as debounce(300, runSearch)
4875
+ const search = wrap(runSearch);
4876
+ ```
4877
+
4878
+ - It works for `latest()`, `leading()`, `debounce(ms)`, `throttle(ms)` and `queue()`, with the options as the last argument: `queue({ concurrency: 2 })`, `debounce(300, { leading: true })`.
4879
+ - Each time you apply a wrapper to a function, you get a new policy with its own state.
4880
+ - To combine policies, nest them: `throttle(100, latest(fn))`.
4881
+ - With `key`, annotate the payload of the key function in TypeScript: `latest({ key: (user: User) => user.id })`. Without the annotation the wrapper cannot know the payload type.
4208
4882
 
4209
4883
  ## Where to go next
4210
4884
 
4211
4885
  - The policies behind this form: [Policies](policies.md).
4212
- - A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
4886
+ - A policy as a plain function without a bus: [`withPolicy`: call a policy outside a bus](policies.md#withpolicy-call-a-policy-outside-a-bus).
4213
4887
  - `retry`, `timeout` and the other effects: [Effects](effects.md).
4214
4888
  - A state library instead of `useState`: [Stores](stores.md).
4215
4889
  - React Query or SWR instead of your own state: [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
@@ -4219,6 +4893,8 @@ Source: docs/chat-example.md
4219
4893
 
4220
4894
  # Build a chat with entities, features, modules and a small server
4221
4895
 
4896
+ > **Level D.** Read first: [Actions, jobs and modules](actions.md).
4897
+
4222
4898
  Use this page when your app has several features that react to each other and you want to see where each piece of logic goes. The example is a chat with rooms, a history that loads, messages that can fail to send, a search across all rooms, and new messages that arrive on their own. A small Fastify server in the example answers every request, and it misbehaves on a known schedule, so each problem on this page really happens.
4223
4899
 
4224
4900
  It is a level D example. Start with [Actions, jobs and modules](actions.md) for the API, or with the `domains-ts` template in [Start a project from a template](starter.md#start-with-domains) for a smaller project.
@@ -4307,7 +4983,7 @@ import { latest } from "nanoeffects/policies";
4307
4983
  export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
4308
4984
  ```
4309
4985
 
4310
- The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job)):
4986
+ The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#the-state-of-a-job)):
4311
4987
 
4312
4988
  ```js
4313
4989
  // message-module.js
@@ -4487,6 +5163,8 @@ Source: docs/chat-widget.md
4487
5163
 
4488
5164
  # Build a chat widget that listens only while it is open
4489
5165
 
5166
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
5167
+
4490
5168
  Use this page when a site has a chat with a bot behind a launcher button, and the chat must cost nothing while it is closed. Closed means no subscriptions on the bus, no polling, no keyboard listener, and no request still running. Opening turns all of it on, and closing turns all of it off in one call.
4491
5169
 
4492
5170
  The code is in [`examples/chat-widget`](../examples/chat-widget): plain DOM and TypeScript, a fake bot in the browser, no server. Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat-widget), or on your machine:
@@ -4621,11 +5299,77 @@ While the chat is closed the launcher holds no subscription at all: it only has
4621
5299
  - Replace the polling by a WebSocket in a spawned task: the abort closes it the same way (see [`examples/trades`](../examples/trades) for a socket under `retry` and `latest`).
4622
5300
  - Watch the whole thing in the [trace](tracing.md): the module, the bot task, the retries and the polling are tasks of one tree.
4623
5301
 
5302
+ ---
5303
+ Source: docs/examples.md
5304
+
5305
+ # Examples
5306
+
5307
+ > **Levels A to F.** Read first: [README](README.md#the-ladder-from-a-to-f).
5308
+
5309
+ 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).
5310
+
5311
+ ## Try it in the browser
5312
+
5313
+ 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.
5314
+
5315
+ | Folder | What is in it |
5316
+ | ------ | ------------- |
5317
+ | [`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. |
5318
+ | [`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. |
5319
+ | [`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. |
5320
+
5321
+ ## Bigger projects
5322
+
5323
+ React and TypeScript projects that show where logic goes when an app has several features.
5324
+
5325
+ | Folder | What is in it |
5326
+ | ------ | ------------- |
5327
+ | [`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). |
5328
+ | [`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`. |
5329
+ | [`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. |
5330
+ | [`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). |
5331
+ | [`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. |
5332
+ | [`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. |
5333
+ | [`trades`](../examples/trades) · [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/trades) | A live stream over a WebSocket: `retry`, `latest` and `throttle`. |
5334
+
5335
+ ## Backend examples
5336
+
5337
+ 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).
5338
+
5339
+ | Folder | What is in it |
5340
+ | ------ | ------------- |
5341
+ | [`api-server`](../examples/api-server) | A server over PokeAPI: `timeout`, `retry`, `allSettled`, and an abort when the client leaves. |
5342
+ | [`job-worker`](../examples/job-worker) | A queue worker: `queue`, `retry`, `timeout`, counts from the outcomes of a job. |
5343
+ | [`ws-server`](../examples/ws-server) | A WebSocket server: one controller per socket, `latest` with a `key`. |
5344
+
5345
+ ## Recipes
5346
+
5347
+ Each file is one job, written as a short program with a comment on every nanoeffects call.
5348
+
5349
+ | File | Job | Docs |
5350
+ | ---- | --- | ---- |
5351
+ | [`search.ts`](../examples/search.ts) | A search box that waits for quiet and aborts the older request. | [Policies](policies.md) |
5352
+ | [`backoff.ts`](../examples/backoff.ts) | Retry a flaky request with a growing pause. | [Effects](effects.md#backoff) |
5353
+ | [`registration-form.ts`](../examples/registration-form.ts) | A sign-up form: a username check that does not race. | [React form](react.md) |
5354
+ | [`scope.ts`](../examples/scope.ts) | An action with its own effects and a test that replaces one. | [Working with context](context.md) |
5355
+ | [`modules.ts`](../examples/modules.ts) | Domain logic with no UI, mounted and unmounted as one unit. | [Actions](actions.md) |
5356
+ | [`redux.ts`](../examples/redux.ts) | Redux Toolkit actions that also reach the bus. | [State managers](stores.md) |
5357
+ | [`guide.ts`](../examples/guide.ts) | The code of every step of the guide. | [Guide](guide.md) |
5358
+
5359
+ ## TypeScript projects
5360
+
5361
+ | Folder | What is in it |
5362
+ | ------ | ------------- |
5363
+ | [`typescript`](../examples/typescript) | Name the event map once and use it in modules and actions. |
5364
+ | [`typescript-context`](../examples/typescript-context) | Give `ctx` the fields of your bus and type them from one place. |
5365
+
4624
5366
  ---
4625
5367
  Source: docs/vue.md
4626
5368
 
4627
5369
  # Use nanoeffects in a Vue app
4628
5370
 
5371
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
5372
+
4629
5373
  Use this page when your app is in Vue 3 and a component has to talk to the bus: tell it that a value changed, and show the result of an event. `vuePlugin` gives you two functions for that. To see them in a running project, start from the `vue-ts` template in [Start a project from a template](starter.md#start-with-vue).
4630
5374
 
4631
5375
  You need Vue 3.3 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
@@ -4718,7 +5462,7 @@ In TypeScript the type of the `ref` comes from the event: with `createAction<str
4718
5462
  ## Where to go next
4719
5463
 
4720
5464
  - The same two jobs for React: [Build a sign-up form in React](react.md).
4721
- - A policy in a component (a debounced save): in `<script setup>` make it once with [`withPolicy`](policies.md#use-a-policy-without-a-bus); Vue runs the setup once, so it needs no hook.
5465
+ - A policy in a component (a debounced save): in `<script setup>` make it once with [`withPolicy`](policies.md#withpolicy-call-a-policy-outside-a-bus); Vue runs the setup once, so it needs no hook.
4722
5466
  - Keep values in Pinia: [Connect Redux, Zustand, MobX, or Pinia](stores.md).
4723
5467
 
4724
5468
  ---
@@ -4726,6 +5470,8 @@ Source: docs/svelte.md
4726
5470
 
4727
5471
  # Use nanoeffects in a Svelte app
4728
5472
 
5473
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
5474
+
4729
5475
  Use this page when your app is in Svelte 5 and a component has to show the result of an event. `sveltePlugin` gives you one function for that, `eventStore`. To see it in a running project, start from the `svelte-ts` template in [Start a project from a template](starter.md#start-with-svelte).
4730
5476
 
4731
5477
  You need Svelte 5 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
@@ -4804,16 +5550,39 @@ bus.on(searchLoaded, (titles) => foundTitles.set(titles));
4804
5550
  ## Where to go next
4805
5551
 
4806
5552
  - The same job for React: [Build a sign-up form in React](react.md); for Vue: [Use nanoeffects in a Vue app](vue.md).
4807
- - A policy in a component (a debounced save): in the script of a component make it once with [`withPolicy`](policies.md#use-a-policy-without-a-bus); Svelte runs the script once, so it needs no hook.
5553
+ - A policy in a component (a debounced save): in the script of a component make it once with [`withPolicy`](policies.md#withpolicy-call-a-policy-outside-a-bus); Svelte runs the script once, so it needs no hook.
4808
5554
 
4809
5555
  ---
4810
5556
  Source: docs/context.md
4811
5557
 
4812
- # Put your own fields on every task
5558
+ # Working with context
5559
+
5560
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus).
5561
+
5562
+ Optional: put a store, client or logger on every task, or give fields to one action, module or call. Skip until many handlers need the same dependency.
5563
+
5564
+ ## On this page
4813
5565
 
4814
- Optional: put a store, client, or logger on every task. Skip until many handlers need the same dependency.
5566
+ - [Context fields](#context-fields)
5567
+ - [Pass values at runtime](#pass-values-at-runtime)
5568
+ - [Type the fields (optional)](#type-the-fields-optional)
5569
+ - [Build a field from the signal of each task](#build-a-field-from-the-signal-of-each-task)
5570
+ - [Scope: fields for an action, a module and a call](#scope-fields-for-an-action-a-module-and-a-call)
5571
+ - [Turn scopes on](#turn-scopes-on)
5572
+ - [Give an action its own fields](#give-an-action-its-own-fields)
5573
+ - [Bind an effect to the task](#bind-an-effect-to-the-task)
5574
+ - [Replace a field for one call](#replace-a-field-for-one-call)
5575
+ - [Give an action, a job or a module its own fields](#give-an-action-a-job-or-a-module-its-own-fields)
5576
+ - [Which value wins](#which-value-wins)
5577
+ - [Keep a generic effect generic](#keep-a-generic-effect-generic)
5578
+ - [Type the fields of a scope](#type-the-fields-of-a-scope)
5579
+ - [Limits](#limits)
5580
+
5581
+ ## Context fields
5582
+
5583
+ ### Pass values at runtime
4815
5584
 
4816
- ## Pass values at runtime
5585
+ <!-- docs-check: new -->
4817
5586
 
4818
5587
  ```js
4819
5588
  import { createBus } from "nanoeffects";
@@ -4826,15 +5595,17 @@ bus.on("save", (draft, { store }) => {
4826
5595
  });
4827
5596
  ```
4828
5597
 
4829
- That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, actions, modules, and child tasks of effects and policies.
5598
+ Here `store` is your own object: a store, an API client, a logger, anything. That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, actions, modules, and child tasks of effects and policies.
4830
5599
 
4831
5600
  Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
4832
5601
 
4833
- To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#put-selected-effects-on-ctx).
5602
+ To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#effectsplugin).
5603
+
5604
+ To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](context.md#scope-fields-for-an-action-a-module-and-a-call).
4834
5605
 
4835
- To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](scope.md).
5606
+ ### Type the fields (optional)
4836
5607
 
4837
- ## Type the fields (optional)
5608
+ <!-- docs-check: new -->
4838
5609
 
4839
5610
  In TypeScript, `contextPlugin` (from `nanoeffects/context`) puts the fields on every task of **this** bus and takes their types from the value you pass. The event map holds only events. It does not wire anything across the app — another bus can have different fields or none.
4840
5611
 
@@ -4856,7 +5627,9 @@ bus.on("save", (draft, { store }) => store.put(draft));
4856
5627
  - 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.
4857
5628
  - The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
4858
5629
 
4859
- ## Build a field from the signal of each task
5630
+ ### Build a field from the signal of each task
5631
+
5632
+ <!-- docs-check: new -->
4860
5633
 
4861
5634
  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
5635
 
@@ -4883,12 +5656,13 @@ bus.on("user/open", async (id, ctx) => {
4883
5656
  });
4884
5657
  ```
4885
5658
 
5659
+ - `signalFetch` from the same entry is a `fetch` bound to a signal, so `fetch: fromSignal(signalFetch)` gives every handler a `ctx.fetch` that stops with the task. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
4886
5660
  - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
4887
5661
  - 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
5662
  - 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
5663
  - Without `contextPlugin` a `fromSignal` marker is only an object.
4890
5664
 
4891
- ### Name the fields in the event map instead
5665
+ #### Name the fields in the event map instead
4892
5666
 
4893
5667
  `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.
4894
5668
 
@@ -4901,10 +5675,7 @@ type Events = { save: Draft } & WithContext<{ store: Store }>;
4901
5675
  const bus = createBus<Events>({ context: { store } });
4902
5676
  ```
4903
5677
 
4904
- ---
4905
- Source: docs/scope.md
4906
-
4907
- # Pin context fields to an action, a module or a call
5678
+ ## Scope: fields for an action, a module and a call
4908
5679
 
4909
5680
  Optional, for domain logic. Use it when one action needs its own client, one module its own logger, or one test its own fake, and a field on the whole bus ([context fields](context.md)) is too wide.
4910
5681
 
@@ -4912,19 +5683,9 @@ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`,
4912
5683
 
4913
5684
  You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
4914
5685
 
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)
5686
+ ### Turn scopes on
4926
5687
 
4927
- ## Turn scopes on
5688
+ <!-- docs-check: new -->
4928
5689
 
4929
5690
  Scopes are a plugin, so a bus that does not use them pays nothing.
4930
5691
 
@@ -4942,7 +5703,9 @@ The bus is in one file and the actions in files named after their domain, so the
4942
5703
  - Without `scopePlugin` a scope is **ignored**: no error and no warning. If a field is missing from `ctx`, check this first.
4943
5704
  - Use it in any order with the other plugins: a scope wins over `effectsPlugin`, `contextPlugin` and `createBus({ context })`.
4944
5705
 
4945
- ## Give an action its own fields
5706
+ ### Give an action its own fields
5707
+
5708
+ <!-- docs-check: new -->
4946
5709
 
4947
5710
  <!-- docs-check: continue -->
4948
5711
 
@@ -4970,7 +5733,9 @@ const user = await bus.emit(getUser("1")); // the handler has api and cache
4970
5733
 
4971
5734
  The listeners of `"user/get"` get the same fields when the action is emitted, because the fields belong to the call.
4972
5735
 
4973
- ## Bind an effect to the task
5736
+ ### Bind an effect to the task
5737
+
5738
+ <!-- docs-check: new -->
4974
5739
 
4975
5740
  An effect is a function with `{ signal }` as its first parameter. In `$effects` it is bound to the task, so it stops with the task.
4976
5741
 
@@ -5015,7 +5780,9 @@ const result = await bus.emit(loadFeed("/feed"));
5015
5780
  - A function under any other key is copied as it is; a function in `$effects` is bound.
5016
5781
  - `$effects` is the one name with a meaning. A field called `effects` is an ordinary field: `{ effects: { retries: 3 } }` puts that object on `ctx`, and nothing in it is bound.
5017
5782
 
5018
- ## Replace a field for one call
5783
+ ### Replace a field for one call
5784
+
5785
+ <!-- docs-check: new -->
5019
5786
 
5020
5787
  The second argument of `emit` is a scope for the tasks of **that** call. It is the usual way to give a test a fake.
5021
5788
 
@@ -5057,7 +5824,9 @@ Rules for the second argument:
5057
5824
  - It can replace `$effects` too: `{ $effects: { delay: instantDelay } }`.
5058
5825
  - Fields of the library (`signal`, `emit`, …) in it are ignored.
5059
5826
 
5060
- ## Give an action, a job or a module its own fields
5827
+ ### Give an action, a job or a module its own fields
5828
+
5829
+ <!-- docs-check: new -->
5061
5830
 
5062
5831
  ```js
5063
5832
  // user.js
@@ -5096,7 +5865,9 @@ If the outcome action (`success` or `fail`) has a scope of its own, its listener
5096
5865
 
5097
5866
  A module scope does not leak: a listener registered outside the module does not see it.
5098
5867
 
5099
- ## Which value wins
5868
+ ### Which value wins
5869
+
5870
+ <!-- docs-check: new -->
5100
5871
 
5101
5872
  When several places set the same field, the strongest one wins:
5102
5873
 
@@ -5109,7 +5880,9 @@ When several places set the same field, the strongest one wins:
5109
5880
 
5110
5881
  The fields of the library (`signal`, `abort`, `emit`, `on`, `off`, `run`, `type`, `payload`) are never replaced. The name `traceGroupId` is reserved: the tracer reads it, and it is not put on `ctx` ([Follow one flow](tracing.md#follow-one-flow-with-a-tracegroupid)). A child task (`ctx.run`, an attempt of `retry`) has the fields of its parent.
5111
5882
 
5112
- ## Keep a generic effect generic
5883
+ ### Keep a generic effect generic
5884
+
5885
+ <!-- docs-check: new -->
5113
5886
 
5114
5887
  In `$effects` a function loses its first parameter, and TypeScript cannot keep a generic through that step: `<T>(items: T[]) => T` becomes `(items: unknown[]) => unknown`. JavaScript code is not affected. If you want the exact type, use one of two ways.
5115
5888
 
@@ -5151,7 +5924,9 @@ const pick = createAction(
5151
5924
 
5152
5925
  `Effect<F>` and `effect` change what the checker sees. At run time the function is the same.
5153
5926
 
5154
- ## Type the fields (optional)
5927
+ ### Type the fields of a scope
5928
+
5929
+ <!-- docs-check: new -->
5155
5930
 
5156
5931
  A handler of an action, job or module with a scope sees the fields of the scope in `ctx`, typed from the values you gave. You write no `WithContext`.
5157
5932
 
@@ -5175,7 +5950,9 @@ const getUser = createAction(
5175
5950
  - A field that no scope and no bus declares has no type to check against, so `emit(call, { unknown: 1 })` is an error in TypeScript.
5176
5951
  - `bus.on(creator, fn)` for an action with a scope types `ctx` with its fields.
5177
5952
 
5178
- ## Limits
5953
+ ### Limits
5954
+
5955
+ <!-- docs-check: new -->
5179
5956
 
5180
5957
  - No plugin, no scope: see [Turn scopes on](#turn-scopes-on).
5181
5958
  - The fields of the bus are not in the handler of an action made with `createAction(…, scope)` unless you type `ctx` yourself; the fields of the scope are.
@@ -5188,6 +5965,8 @@ Source: docs/oop.md
5188
5965
 
5189
5966
  # Use the bus from a class
5190
5967
 
5968
+ > **Level D.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
5969
+
5191
5970
  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:
5192
5971
 
5193
5972
  | You want | Use |
@@ -5195,7 +5974,7 @@ Use this page when **your domain logic lives in classes**: a service, a store or
5195
5974
  | every `new Service()` to be an independent emitter | `extends Emitter` |
5196
5975
  | the instances to share a bus, or to use plugins | `extends extendBus(bus)` |
5197
5976
 
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.
5977
+ If your logic is plain functions, stay with [modules](actions.md#modules): a module is the same idea without a class.
5199
5978
 
5200
5979
  ## On this page
5201
5980
 
@@ -5621,6 +6400,8 @@ Source: docs/typescript.md
5621
6400
 
5622
6401
  # Use nanoeffects in a TypeScript app with many files
5623
6402
 
6403
+ > **Level D.** Read first: [Guide](guide.md#10-add-types-when-you-want-checking-optional).
6404
+
5624
6405
  Read this page if your app is in TypeScript and the handlers and modules live in different files. It shows how to write an action with a handler so that TypeScript infers its payload and result, and what to add when the handlers need the fields of your bus (`ctx.store`, the effects of `effectsPlugin`). For one file or for JavaScript you do not need it.
5625
6406
 
5626
6407
  ## Write an action with its handler
@@ -5793,6 +6574,8 @@ Source: docs/migrating-from-saga.md
5793
6574
 
5794
6575
  # Coming from redux-saga
5795
6576
 
6577
+ > **Level E, for readers who know redux-saga.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
6578
+
5796
6579
  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).
5797
6580
 
5798
6581
  ## Find the equivalent
@@ -5892,6 +6675,8 @@ Source: docs/migrating-from-effector.md
5892
6675
 
5893
6676
  # Coming from Effector
5894
6677
 
6678
+ > **Level E, for readers who know Effector.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
6679
+
5895
6680
  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.
5896
6681
 
5897
6682
  ## On this page
@@ -5923,7 +6708,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
5923
6708
  | event call `loaded(user)` | `bus.emit("loaded", user)` |
5924
6709
  | `createEffect(handler)` | a handler: `bus.on(event, handler)` |
5925
6710
  | effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
5926
- | `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)) |
6711
+ | `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)) |
5927
6712
  | `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
5928
6713
  | `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
5929
6714
  | `sample({ filter })` | an `if` in the handler |
@@ -6221,7 +7006,7 @@ bus.on(
6221
7006
  ## What is different
6222
7007
 
6223
7008
  - **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)).
6224
- - **`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).
7009
+ - **`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).
6225
7010
  - **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.
6226
7011
  - **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.
6227
7012
  - **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
@@ -6231,9 +7016,11 @@ bus.on(
6231
7016
  ---
6232
7017
  Source: docs/migrating-from-mitt.md
6233
7018
 
6234
- # Coming from mitt, nanoevents or EventEmitter
7019
+ # Coming from mitt and nanoevents
6235
7020
 
6236
- 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.
7021
+ > **Level A, for readers who know mitt or nanoevents.** Read first: nothing: the bus is the same idea.
7022
+
7023
+ 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.
6237
7024
 
6238
7025
  ## When to switch
6239
7026
 
@@ -6248,8 +7035,8 @@ If handlers stay synchronous, keep your emitter. nanoeffects costs about 1.4 kB
6248
7035
 
6249
7036
  ## Find the equivalent
6250
7037
 
6251
- | mitt / EventEmitter | nanoeffects |
6252
- | ------------------- | ----------- |
7038
+ | mitt / nanoevents | nanoeffects |
7039
+ | ----------------- | ----------- |
6253
7040
  | `emitter.on("a", handler)` | `bus.on("a", handler)` |
6254
7041
  | `emitter.off("a", handler)` | call the function that `bus.on` returned, or `bus.off("a")` for all handlers of `a` |
6255
7042
  | `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
@@ -6334,6 +7121,8 @@ Source: docs/migrating-from-rxjs.md
6334
7121
 
6335
7122
  # Use nanoeffects next to RxJS
6336
7123
 
7124
+ > **Level E, for readers who know RxJS.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
7125
+
6337
7126
  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.
6338
7127
 
6339
7128
  ## Split the work
@@ -6533,6 +7322,8 @@ Source: docs/semantics.md
6533
7322
 
6534
7323
  # Semantics
6535
7324
 
7325
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 6.
7326
+
6536
7327
  Guarantees and sharp edges. Read when something surprises you; not required to start.
6537
7328
 
6538
7329
  ## Five rules to know first
@@ -6551,11 +7342,363 @@ Details of each rule and the rest:
6551
7342
  - **`leading` releases its lock when the task aborts**, not only when `fn` settles. A `fn` that ignores the abort signal can then overlap with the next run — pass `ctx.signal` to what `fn` does.
6552
7343
  - **Effects start their function synchronously**, so an event emitted right after the call reaches a `take` inside it.
6553
7344
 
7345
+ ---
7346
+ Source: docs/pass-the-signal.md
7347
+
7348
+ # Keep the signal through a handler
7349
+
7350
+ > **Level B.** Read first: [Policies](policies.md#latest).
7351
+
7352
+ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. The handler stops only if every step that waits gets that signal. One request without it keeps running, and its result arrives after the newer one. This page lists the ways the signal gets lost, what catches each one before the code runs (a linter) and while it runs (`devPlugin`), and how to fix it.
7353
+
7354
+ ## On this page
7355
+
7356
+ - [Know what the signal does](#know-what-the-signal-does)
7357
+ - [Bind the signal once](#bind-the-signal-once)
7358
+ - [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
7359
+ - [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
7360
+ - [Turn on the checks](#turn-on-the-checks)
7361
+ - [Choose which calls are checked](#choose-which-calls-are-checked)
7362
+ - [Count lost signals in a test](#count-lost-signals-in-a-test)
7363
+ - [Prove it with a test](#prove-it-with-a-test)
7364
+ - [Know what the checks cannot see](#know-what-the-checks-cannot-see)
7365
+
7366
+ ## Know what the signal does
7367
+
7368
+ `ctx.signal` is an `AbortSignal`. The policy aborts it when the call is cancelled (a newer call of `latest`, a `debounce` that was replaced, an unmounted module). A step stops when you give it the signal: `fetch(url, { signal: ctx.signal })`, `api.search(query, { signal: ctx.signal })`, or the whole `ctx` for an effect, `retry(ctx, ...)`. Everything else keeps running. The bus drops what a cancelled handler emits with `ctx.emit`, so the loss shows up late and quietly.
7369
+
7370
+ ## Bind the signal once
7371
+
7372
+ The surest fix is a handler with no signal in it. Make the client from the signal of the task, once, and the handler calls `ctx.fetch` and cannot forget anything. `signalFetch` is a `fetch` bound to a signal; `fromSignal` builds it for each task.
7373
+
7374
+ ```js
7375
+ // bus.js
7376
+ import { createBus } from "nanoeffects";
7377
+ import { contextPlugin } from "nanoeffects/context";
7378
+ import { fromSignal, signalFetch } from "nanoeffects/signal";
7379
+ import axios from "axios";
7380
+
7381
+ // fromSignal — ctx.fetch is made from the signal of the task that reads it
7382
+ export const bus = createBus().use(
7383
+ contextPlugin({
7384
+ fetch: fromSignal(signalFetch),
7385
+ // your own client too: it is made from the signal of each task
7386
+ http: fromSignal((signal) => axios.create({ signal })),
7387
+ }),
7388
+ );
7389
+ ```
7390
+
7391
+ ```js
7392
+ // search-handlers.js
7393
+ import { latest } from "nanoeffects/policies";
7394
+ import { bus } from "./bus";
7395
+
7396
+ bus.on(
7397
+ "search",
7398
+ latest(async (query, ctx) => {
7399
+ // no signal here: ctx.fetch stops when the policy cancels the handler
7400
+ const response = await ctx.fetch(`/api?q=${query}`);
7401
+ ctx.emit("results", await response.json());
7402
+ }),
7403
+ );
7404
+ ```
7405
+
7406
+ A call with a `signal` of its own stops with whichever aborts first. Then make the linter hold the line: with `requireContext: true`, every call that a handler waits for must go through `ctx` or be given `ctx`, so a global `fetch` or `api.search(...)` is an error even when the signal is passed by hand ([turn on the checks](#turn-on-the-checks)).
7407
+
7408
+ ## Fix a handler that loses the signal
7409
+
7410
+ The two shapes you meet first, and the fix:
7411
+
7412
+ ```js
7413
+ import { createBus } from "nanoeffects";
7414
+ import { latest } from "nanoeffects/policies";
7415
+
7416
+ const bus = createBus();
7417
+
7418
+ // 1. ctx is taken and the signal is lost: the request cannot stop
7419
+ bus.on(
7420
+ "search",
7421
+ latest(async (query, ctx) => {
7422
+ const items = await api.search(query);
7423
+ ctx.emit("results", items);
7424
+ }),
7425
+ );
7426
+
7427
+ // 2. one step passes it and another does not: the first one cannot stop
7428
+ bus.on(
7429
+ "feed",
7430
+ latest(async (query, ctx) => {
7431
+ const news = await api.news(query);
7432
+ const posts = await api.posts(query, { signal: ctx.signal });
7433
+ ctx.emit("results", { news, posts });
7434
+ }),
7435
+ );
7436
+
7437
+ // the fix: every step that waits gets the signal
7438
+ bus.on(
7439
+ "feed/fixed",
7440
+ latest(async (query, ctx) => {
7441
+ const news = await api.news(query, { signal: ctx.signal });
7442
+ const posts = await api.posts(query, { signal: ctx.signal });
7443
+ ctx.emit("results", { news, posts });
7444
+ }),
7445
+ );
7446
+ ```
7447
+
7448
+ ## Pass the signal when it is hard
7449
+
7450
+ Each row is a situation where the signal is easy to lose. The right column says how to keep it. "Lint" is the rule from [`examples/lint`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/lint) that flags it, and "devPlugin" is the warning at run time ([details](hmr.md#find-a-handler-that-ignores-the-signal)).
7451
+
7452
+ | Situation | What to do | Lint | devPlugin |
7453
+ | --------- | ---------- | :--: | :-------: |
7454
+ | The handler has no `ctx`: `latest(async (query) => ...)` | Take `(query, ctx)` and pass `ctx.signal` | yes | |
7455
+ | `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request, or bind it once: `ctx.fetch` ([above](#bind-the-signal-once)) | yes | yes |
7456
+ | One step passes the signal, another does not | Pass it to every step | yes | yes |
7457
+ | The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
7458
+ | Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
7459
+ | `Promise.all(ids.map((id) => load(id)))` | Pass it inside the callback | yes | |
7460
+ | The step is inside an effect: `retry(ctx, () => api.search(q))` | The callback is not given the signal. Pass `{ signal: ctx.signal }` in it, or use the child context it gets | yes | |
7461
+ | `timeout(ctx, 1000, (child) => api.search(q))` | Pass `child`, or `child.signal`, to the request | yes | |
7462
+ | `ctx.run((child) => api.search(q))` | Pass `child` | yes | |
7463
+ | A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
7464
+ | A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
7465
+ | A chain: `api.news(q).then(...)` | Pass the signal to the first call | yes | |
7466
+ | Another signal: `AbortSignal.timeout(5000)`, or the signal of your own `AbortController` | Join it with the handler's: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])` | yes | |
7467
+ | Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
7468
+ | A library that takes no signal (a cache, a local database) | List it in `ignore`, or check only the calls on your list with `only`. With type information the rule skips a function that takes no signal on its own | `ignore`, `only`, types | |
7469
+ | A function takes the signal and never uses it: `search(query, options) { return fetch(url); }` | Pass the signal on inside it. Only type information finds this in the linter; a test finds it for sure | types | |
7470
+ | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
7471
+ | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
7472
+ | A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx.fetch` | | yes |
7473
+ | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
7474
+ | `ctx.on` after the cancel | The same | | yes |
7475
+
7476
+ The pause and the effect rows in code:
7477
+
7478
+ ```js
7479
+ import { createBus } from "nanoeffects";
7480
+ import { delay, retry, timeout } from "nanoeffects/effects";
7481
+ import { latest } from "nanoeffects/policies";
7482
+
7483
+ const bus = createBus();
7484
+
7485
+ bus.on(
7486
+ "search",
7487
+ latest(async (query, ctx) => {
7488
+ // delay — a pause that stops with ctx, not new Promise(setTimeout)
7489
+ await delay(ctx, 300);
7490
+
7491
+ // retry — the attempts stop with ctx, but the request needs the signal too
7492
+ const items = await retry(ctx, () => api.search(query, { signal: ctx.signal }));
7493
+
7494
+ // timeout — the callback gets a child context: give it to the request
7495
+ const details = await timeout(ctx, 5000, (child) => api.details(query, child));
7496
+
7497
+ ctx.emit("results", { items, details });
7498
+ }),
7499
+ );
7500
+ ```
7501
+
7502
+ Calls that run together, and a signal that joins another:
7503
+
7504
+ ```js
7505
+ import { createBus } from "nanoeffects";
7506
+ import { latest } from "nanoeffects/policies";
7507
+
7508
+ const bus = createBus();
7509
+
7510
+ bus.on(
7511
+ "feed",
7512
+ latest(async (query, ctx) => {
7513
+ // each call of the group gets the signal, not only the first
7514
+ const [news, posts] = await Promise.all([
7515
+ api.news(query, { signal: ctx.signal }),
7516
+ api.posts(query, { signal: ctx.signal }),
7517
+ ]);
7518
+
7519
+ // AbortSignal.any — stops with the handler or after 5 seconds, whichever comes first
7520
+ const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)]);
7521
+ const details = await api.details(query, { signal });
7522
+
7523
+ ctx.emit("results", { news, posts, details });
7524
+ }),
7525
+ );
7526
+ ```
7527
+
7528
+ ## Turn on the checks
7529
+
7530
+ **While you write.** [`examples/lint`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/lint) has a rule for ESLint and oxlint (one file, no dependency) and two GritQL plugins for Biome. Copy the file, turn the rule on, and give it the names of calls that cannot be cancelled:
7531
+
7532
+ <!-- docs-check: skip - a config file of the reader's project -->
7533
+ ```js
7534
+ // eslint.config.mjs
7535
+ import nanoeffects from "./nanoeffects-lint.mjs";
7536
+
7537
+ export default [
7538
+ {
7539
+ files: ["**/*.{js,mjs,cjs,jsx,ts,tsx}"],
7540
+ plugins: { nanoeffects },
7541
+ rules: {
7542
+ "nanoeffects/policy-handler-uses-signal": [
7543
+ "warn",
7544
+ // calls that take no signal and are fine to wait for
7545
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
7546
+ ],
7547
+ },
7548
+ },
7549
+ ];
7550
+ ```
7551
+
7552
+ For oxlint list the file in `jsPlugins` of `.oxlintrc.json` and use the same rule name. For Biome list the two `.grit` files in `plugins`. The Biome plugins read text, so they flag the direct steps (the first rows above) and miss the deeper ones: the calls of `Promise.all`, the callbacks of effects, `for await`, a pause, and the key of an options object.
7553
+
7554
+ **While you run.** `devPlugin` warns once when a handler emits, or subscribes with `ctx.on`, after its task was cancelled:
7555
+
7556
+ ```js
7557
+ import { createBus } from "nanoeffects";
7558
+ import { devPlugin } from "nanoeffects/dev";
7559
+
7560
+ const bus = createBus();
7561
+
7562
+ // devPlugin — warns about a handler that kept running after it was cancelled; nothing in production
7563
+ bus.use(devPlugin());
7564
+ ```
7565
+
7566
+ ## Choose which calls are checked
7567
+
7568
+ The rule cannot tell, from the call alone, whether the function takes a signal. Ways to tell it:
7569
+
7570
+ - **`requireContext`** needs no list: set it to `true` when handlers make requests through `ctx` (see [Bind the signal once](#bind-the-signal-once)), and any call that does not go through `ctx` is reported. `ignore` still skips a call.
7571
+ - **`ignore`** lists the calls to skip, by name: `ignore: ["cache.get", "storage.*"]`. Use it when most calls take a signal and a few cannot.
7572
+ - **`only`** lists the calls to check, by name: `only: ["api.*", "fetch"]`. Anything else is left alone, so a call that is not on the list is never reported. Use it when you know which clients take a signal. A call on both lists is skipped, and a pause that nothing can cancel (`await new Promise(...)`) is reported whatever `only` says.
7573
+ - **Type information** needs no list. With `@typescript-eslint/parser` and a `project`, the rule reads the function it calls. A call that does not get the signal is reported only when the function takes one (a parameter typed `AbortSignal`, or an options object with `signal`), so `cache.get(key)` is left alone. A call that passes the signal is reported when the function is in your sources and never uses it: the parameter is unused, or only other options are read from it. A signal forwarded to another function of yours is followed up to three calls deep, and a function from a library is trusted. A callee typed `any` is reported as before.
7574
+
7575
+ <!-- docs-check: skip - a config file of the reader's project -->
7576
+ ```js
7577
+ // eslint.config.mjs
7578
+ import parser from "@typescript-eslint/parser";
7579
+ import nanoeffects from "./nanoeffects-lint.mjs";
7580
+
7581
+ export default [
7582
+ {
7583
+ files: ["**/*.{ts,tsx}"],
7584
+ // the parser gives the rule the type checker of your project
7585
+ languageOptions: { parser, parserOptions: { project: "./tsconfig.json" } },
7586
+ plugins: { nanoeffects },
7587
+ rules: { "nanoeffects/policy-handler-uses-signal": "warn" },
7588
+ },
7589
+ ];
7590
+ ```
7591
+
7592
+ oxlint and Biome do not give a rule type information, so they use the syntax and the lists. The type-aware mode needs the `typescript` package with the compiler API (version 5 today; the native version 7 has none), as `@typescript-eslint/parser` does.
7593
+
7594
+ ## Count lost signals in a test
7595
+
7596
+ `getLostSignalsCount(bus)` from `nanoeffects/dev` returns how many signals the handlers of a bus lost: each emit or `ctx.on` that a handler made after its task was cancelled. The bus needs `devPlugin`. Run the flow the way a user would, cancel it, and check that the count is 0:
7597
+
7598
+ ```js
7599
+ import { createBus } from "nanoeffects";
7600
+ import { devPlugin, getLostSignalsCount, getLostSignals } from "nanoeffects/dev";
7601
+ import { latest } from "nanoeffects/policies";
7602
+ import { expect, test } from "vitest";
7603
+
7604
+ test("a newer search leaves no lost signals", async () => {
7605
+ const bus = createBus();
7606
+ // devPlugin — records every emit that a cancelled handler made; log hides its warnings in the test
7607
+ bus.use(devPlugin({ log: () => {} }));
7608
+ bus.on(
7609
+ "search",
7610
+ latest(async (query, ctx) => {
7611
+ const items = await api.search(query, { signal: ctx.signal });
7612
+ ctx.emit("results", items);
7613
+ }),
7614
+ );
7615
+
7616
+ bus.emit("search", "a");
7617
+ bus.emit("search", "ab"); // cancels the first call
7618
+
7619
+ // wait — how long to let the handlers finish before counting, in ms
7620
+ const count = await getLostSignalsCount(bus, { wait: 50 });
7621
+
7622
+ expect(count).toBe(0);
7623
+ });
7624
+ ```
7625
+
7626
+ `getLostSignals(bus)` gives the list instead of the count: `{ kind: "emit" | "on", handler, event }` for each, so a failing test says which handler and which event. Without `devPlugin` (or with `abandoned: false`) it throws and says so. In a production build both functions are empty: the count is 0.
7627
+
7628
+ On Node (tests, servers) it counts requests too. `devPlugin` follows the `fetch` calls that a handler makes, also after `await`, and counts one with `kind: "request"` when its task was cancelled while the request was still running and the request did not stop: it got no signal, or a signal that is not the task's. You add nothing to the test. In a browser there is no way to tell which task made a request, so only the emits and subscriptions are counted. `requests: false` turns the tracking off.
7629
+
7630
+ ```js
7631
+ import { createBus } from "nanoeffects";
7632
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
7633
+ import { latest } from "nanoeffects/policies";
7634
+
7635
+ const bus = createBus();
7636
+ bus.use(devPlugin({ log: () => {} }));
7637
+
7638
+ bus.on(
7639
+ "search",
7640
+ latest(async (query) => {
7641
+ await fetch(`/api?q=${query}`); // no signal
7642
+ }),
7643
+ );
7644
+
7645
+ bus.emit("search", "a");
7646
+ bus.emit("search", "ab");
7647
+
7648
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
7649
+ console.log(await getLostSignals(bus, { wait: 50 }));
7650
+ ```
7651
+
7652
+ A request that finished before the cancel is not counted. For another client (axios, a driver) bind it with `fromSignal`, or stub it in a test as the next section does for `fetch`.
7653
+
7654
+ ## Prove it with a test
7655
+
7656
+ A linter reads code; it cannot know what a function does with the signal. A test can. The idea: replace `fetch` with a server that answers when the test lets it and stops when the request's signal aborts, run the handler, cancel it in the middle of each step, and list the requests that did not stop. No timers are involved, so the result does not depend on how fast the machine is.
7657
+
7658
+ <!-- docs-check: skip - a test file of the reader's project -->
7659
+ ```js
7660
+ // search.cancel.test.js
7661
+ import { createBus } from "nanoeffects";
7662
+ import { latest } from "nanoeffects/policies";
7663
+ import { expect, test } from "vitest";
7664
+ // copy prove-cancel.js from examples/lint; it needs no package
7665
+ import { requestsThatKeptRunning } from "./prove-cancel.js";
7666
+
7667
+ test("a newer search stops the older one at every step", async () => {
7668
+ const bus = createBus();
7669
+ bus.on(
7670
+ "search",
7671
+ latest(async (query, ctx) => {
7672
+ const news = await api.news(query, { signal: ctx.signal });
7673
+ const posts = await api.posts(query, { signal: ctx.signal });
7674
+ ctx.emit("results", { news, posts });
7675
+ }),
7676
+ );
7677
+
7678
+ // for each step: start a call, cancel it with a newer one, and collect the requests that did not stop
7679
+ const kept = await requestsThatKeptRunning((value) => bus.emit("search", value));
7680
+
7681
+ // empty when every request got a signal that aborted with the handler
7682
+ expect(kept).toEqual([]);
7683
+ });
7684
+ ```
7685
+
7686
+ `kept` has the url of each request that went on, the step where the handler was cancelled, and whether the request got a signal at all (`hadSignal: false`) or one that is not the handler's (`hadSignal: true`). This finds what the linter cannot: `api.posts` that takes `{ signal }` and ignores it, an `AbortSignal.timeout` in place of the handler's signal, a request in a branch you did not think of. It checks the boundary, `fetch`; for another client (axios, a database driver) stub that client in the same way. The repository tests the helper on handlers that forward the signal, ignore it, drop it at the third step, and use a signal of their own.
7687
+
7688
+ ## Know what the checks cannot see
7689
+
7690
+ - **A handler that is not written in place.** `latest(searchHandler)` and `latest(withLogging(async (query, ctx) => ...))` are not read by the linter.
7691
+ - **A callee that ignores the signal you pass.** Without type information the rule sees that you passed it, not what the callee does with it. With it, the rule reads callees in your sources; a library is trusted. The test above checks the real requests.
7692
+ - **A handler that never emits.** `devPlugin` sees a lost signal by an emit after the cancel; a handler that only writes elsewhere is seen by the linter alone.
7693
+ - **Shadowed names.** The rule uses no scope analysis, so an inner variable named `signal` can hide a lost signal.
7694
+
6554
7695
  ---
6555
7696
  Source: docs/tracing.md
6556
7697
 
6557
7698
  # Trace tasks
6558
7699
 
7700
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
7701
+
6559
7702
  Find out what your app did and why: which events were emitted, which calls a policy cancelled or dropped, which request failed. You see it in the browser console, in Redux DevTools, or in the Performance panel.
6560
7703
 
6561
7704
  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.
@@ -6573,6 +7716,8 @@ Tracing is optional. A bus works the same without it, and it costs almost nothin
6573
7716
  - [Cut volume in production](#cut-volume-in-production)
6574
7717
  - [Keep the last events for an incident](#keep-the-last-events-for-an-incident)
6575
7718
  - [Configure Redux DevTools](#configure-redux-devtools)
7719
+ - [Find loops with `graphLogger`](#find-loops-with-graphlogger)
7720
+ - [Send safe data to a tool](#send-safe-data-to-a-tool)
6576
7721
  - [Write your own logger](#write-your-own-logger)
6577
7722
  - [Drop tracing from a build](#drop-tracing-from-a-build)
6578
7723
 
@@ -6611,7 +7756,7 @@ Phases:
6611
7756
  - `cancel` — aborted after it started (superseded by `latest`, parent aborted, lost a `race`).
6612
7757
  - `drop` — never started (`leading` while busy, a `throttle` call replaced by a newer one, a `debounce` call replaced before its delay ended).
6613
7758
 
6614
- `all` and `race` are traced when you pass them the handler `ctx`: the group is one task; every starter is a child `run` task.
7759
+ `all` and `race` are traced when you pass them the handler `ctx`: the group is one task; every function of the group is a child `run` task.
6615
7760
 
6616
7761
  ```js
6617
7762
  import { createBus } from "nanoeffects";
@@ -6803,6 +7948,7 @@ const bus = createBus({
6803
7948
  | `consoleLogger()` | Prints one line per phase, indented by task depth |
6804
7949
  | `performanceLogger()` | User Timing measures for the browser Performance panel |
6805
7950
  | `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)) |
7951
+ | `graphLogger()` | Builds the map of which event leads to which, finds loops while the app runs, and writes the map as Mermaid, Graphviz or JSON ([see below](#find-loops-with-graphlogger)) |
6806
7952
 
6807
7953
  ### Cut volume in production
6808
7954
 
@@ -6935,6 +8081,58 @@ reduxDevtools({
6935
8081
 
6936
8082
  With two quick `search` emits and a `latest` handler, the default list is `search`, `search`. With `policies: true` it adds the `latest` stages of each call, for example `search/latest/cancel` for the first one.
6937
8083
 
8084
+ ### Find loops with `graphLogger`
8085
+
8086
+ Two handlers that emit each other's events never stop. `graphLogger` is a logger that finds such a loop while the app runs, writes a report once for it, and keeps the map of the events so you can draw it.
8087
+
8088
+ ```js
8089
+ // bus.js
8090
+ import { createBus } from "nanoeffects";
8091
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8092
+
8093
+ // graphLogger — the map of events and the loop finder, in one logger
8094
+ export const graph = graphLogger({ name: "app" });
8095
+
8096
+ // createTracer — sends every task of the bus to the logger
8097
+ const trace = createTracer(graph);
8098
+
8099
+ // tracePlugin — also reports each emit; the logger works without it, with a coarser map
8100
+ export const bus = createBus({ trace }).use(tracePlugin());
8101
+
8102
+ // graph.cycles() lists the loops found; graph.toMermaid() draws the map
8103
+ ```
8104
+
8105
+ It reports a node that is in one chain three times, a storm of emits from outside any handler, and a chain that starts too many tasks. It writes to `console.warn`, or to `onCycle`, or to Redux DevTools with `devtools: true`. Nothing is stopped: to cut a loop use [`loopGuardPlugin`](loop-guard.md). The full walk-through, with scenarios you can run, is [Find and cut event loops](debugging-loops.md).
8106
+
8107
+ ### Send safe data to a tool
8108
+
8109
+ A payload or an error can be anything: a class instance, a `Map`, a circular object, a function, an `Error` with a `cause`. A tool that gets such a value as it is can fail or show nothing. The built-in loggers make sure that what leaves your process is plain data:
8110
+
8111
+ | Logger | What it does with a payload and an error |
8112
+ | ------ | ---------------------------------------- |
8113
+ | `reduxDevtools()` | Makes both plain data with `safeSerialize` before the extension gets them. An `Error` is sent as its message. `serialize` changes this. |
8114
+ | `performanceLogger()` | Sends ids and the phase; an error is text. A payload is not sent. |
8115
+ | `consoleLogger()` | Passes the value to `console`, which prints any value and keeps it open for you to inspect. |
8116
+ | `createTraceRecorder()` | Keeps the events as they are. `recorder.toJSON()` and `JSON.stringify(recorder)` give plain data. |
8117
+ | `graphLogger()` | Exports no payload unless you ask, and then makes it plain. |
8118
+
8119
+ `safeSerialize(value)` is exported for your own logger. It never throws and never loops: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ $map }`, a `Set` becomes `{ $set }`, an instance of a class keeps its own fields and its name as `$class`, and a getter is never called. A value that is too deep or too big is cut.
8120
+
8121
+ ```js
8122
+ import { createTracer, reduxDevtools, safeSerialize } from "nanoeffects/trace";
8123
+
8124
+ const trace = createTracer(
8125
+ reduxDevtools({
8126
+ name: "app",
8127
+ // serialize — runs on every payload and error before the extension gets it;
8128
+ // hide a secret first, then make the rest plain
8129
+ serialize: (value) => safeSerialize(value, { maxDepth: 4 }),
8130
+ }),
8131
+ );
8132
+ ```
8133
+
8134
+ `serialize: false` sends values as they are, which is faster and can fail on a circular one.
8135
+
6938
8136
  ### Write your own logger
6939
8137
 
6940
8138
  ```ts
@@ -6964,11 +8162,448 @@ conditions: ["notrace"]
6964
8162
 
6965
8163
  In a build from the sources, `define: { __NANOEFFECTS_TRACE__: "false" }` does the same. With the condition set, that build’s types have no `trace` option: `createBus({ trace })` is a type error. `nanoeffects/trace` has no `notrace` build. The saving is about 0.4 kB gzip of the whole library.
6966
8164
 
8165
+ ---
8166
+ Source: docs/debugging-loops.md
8167
+
8168
+ # Find and cut event loops
8169
+
8170
+ > **Level F.** Read first: [Trace tasks](tracing.md#attach-a-tracer). To only cut a loop, go to [`loopGuardPlugin`](loop-guard.md).
8171
+
8172
+ A loop is two handlers that emit each other's events, or one handler that emits its own. This page finds a loop, shows it, and stops it. Every scenario below is a runnable file in [`examples/debug`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug), and a page with buttons and Redux DevTools is in [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
8173
+
8174
+ ## On this page
8175
+
8176
+ - [Start from the symptom](#start-from-the-symptom)
8177
+ - [Find a loop](#find-a-loop)
8178
+ - [Read a report](#read-a-report)
8179
+ - [Draw the graph](#draw-the-graph)
8180
+ - [See the loop in Redux DevTools](#see-the-loop-in-redux-devtools)
8181
+ - [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows)
8182
+ - [Break the loop](#break-the-loop)
8183
+ - [Cut a loop at run time](#cut-a-loop-at-run-time)
8184
+ - [Fail a test on a loop](#fail-a-test-on-a-loop)
8185
+ - [Export the graph and the payloads](#export-the-graph-and-the-payloads)
8186
+ - [Know the limits](#know-the-limits)
8187
+
8188
+ ## Start from the symptom
8189
+
8190
+ | What you see | Likely cause | Go to |
8191
+ | ------------ | ------------ | ----- |
8192
+ | The page freezes | A loop of microtasks or a synchronous loop | [Find a loop](#find-a-loop), then [Cut a loop at run time](#cut-a-loop-at-run-time) |
8193
+ | A request repeats on its own | A handler emits the event that starts it | [Read a report](#read-a-report) |
8194
+ | `RangeError: Maximum call stack size exceeded` | A synchronous loop through `emit` | [Find a loop](#find-a-loop) |
8195
+ | A component renders again and again | A value is sent to the bus on every render | [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows) |
8196
+ | You do not know which event leads to which | No map of the events | [Draw the graph](#draw-the-graph) |
8197
+
8198
+ ## Find a loop
8199
+
8200
+ `graphLogger` is a trace logger. Pass it to `createTracer` like any other logger. It builds the map of which event leads to which, and writes a report once when a chain passes the same event three times. Add `tracePlugin` so that it also sees every emit and the names of your listeners. It works without the plugin, with a coarser map.
8201
+
8202
+ ```js
8203
+ // bus.js
8204
+ import { createBus } from "nanoeffects";
8205
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8206
+
8207
+ // graphLogger — the map of events and the loop finder, in one logger
8208
+ export const graph = graphLogger({ name: "cart" });
8209
+
8210
+ // createTracer — sends every task of the bus to the logger
8211
+ const trace = createTracer(graph);
8212
+
8213
+ // tracePlugin — reports every emit, so the graph also shows events that nothing listens to
8214
+ export const bus = createBus({ trace }).use(tracePlugin());
8215
+ ```
8216
+
8217
+ The handlers are the part of your app that has the loop. Here a cart and a total update each other:
8218
+
8219
+ ```js
8220
+ // cart-handlers.js
8221
+ import { bus } from "./bus";
8222
+
8223
+ // traceId — the name of the listener in the graph and in the report
8224
+ bus.on(
8225
+ "cart/changed",
8226
+ (cart, ctx) => {
8227
+ ctx.emit("total/changed", cart);
8228
+ },
8229
+ { traceId: "recalcTotal" },
8230
+ );
8231
+
8232
+ bus.on(
8233
+ "total/changed",
8234
+ (total, ctx) => {
8235
+ ctx.emit("cart/changed", total);
8236
+ },
8237
+ { traceId: "syncCart" },
8238
+ );
8239
+ ```
8240
+
8241
+ `graphLogger` costs a lookup for each task. Use it while you develop, or with `createTracer(graph, { sample })` in production.
8242
+
8243
+ ## Read a report
8244
+
8245
+ <!-- docs-check: continue -->
8246
+
8247
+ The report is one `console.warn` for each loop:
8248
+
8249
+ ```text
8250
+ [nanoeffects cart] cycle: recalcTotal → total/changed → syncCart → cart/changed → recalcTotal (repeated 3 times, started at cart/changed)
8251
+ recalcTotal at src/cart-handlers.js:6; syncCart at src/cart-handlers.js:14 (only with devPlugin)
8252
+ handlers feed each other: break the loop with a condition, or emit a different event for the result
8253
+ ```
8254
+
8255
+ - **The path** goes from the first turn of the loop back to it. A named listener shows its event before it.
8256
+ - **`started at`** is the first event of the chain.
8257
+ - **`at file:line`** appears when you also use [`devPlugin`](hmr.md#report-leaks-with-devplugin).
8258
+ - **The last line** is advice for this shape of loop.
8259
+
8260
+ To receive the finding in code, pass `onCycle`. It replaces the console report:
8261
+
8262
+ ```js
8263
+ // report.js
8264
+ import { graphLogger } from "nanoeffects/trace";
8265
+
8266
+ export const graph = graphLogger({
8267
+ // onCycle — called once for each loop; the finding has a path, the repeats and the root
8268
+ onCycle: (finding) => {
8269
+ console.error(finding.kind, finding.path.join(" → "));
8270
+ },
8271
+ });
8272
+ ```
8273
+
8274
+ `graph.cycles()` lists the loops found so far, with `turns`: how many times a loop went round after it was found. It also lists cycles that no single chain showed, when the edges of different runs close a loop (`source: "graph"`).
8275
+
8276
+ ## Draw the graph
8277
+
8278
+ <!-- docs-check: continue -->
8279
+
8280
+ The graph has a count on each edge, and the edges of a cycle are marked. Write it in the format your tool reads:
8281
+
8282
+ ```js
8283
+ // draw.js
8284
+ import { graph } from "./bus";
8285
+
8286
+ // Mermaid: paste it into a README, a pull request or https://mermaid.live
8287
+ const mermaid = graph.toMermaid({ group: "prefix" }); // group the events by the part before "/"
8288
+
8289
+ // Graphviz: dot -Tsvg graph.dot > graph.svg
8290
+ const dot = graph.toDot();
8291
+
8292
+ // plain data for your own tools
8293
+ const data = graph.toJSON();
8294
+
8295
+ console.log(mermaid, dot, data);
8296
+ ```
8297
+
8298
+ ```mermaid
8299
+ flowchart LR
8300
+ n0["cart/changed"]
8301
+ n1["recalcTotal"]
8302
+ n2["total/changed"]
8303
+ n3["syncCart"]
8304
+ n0 -->|x21| n1
8305
+ n1 -->|x20| n2
8306
+ n2 -->|x20| n3
8307
+ n3 -->|x20| n0
8308
+ linkStyle 0,1,2,3 stroke:red,stroke-width:2px
8309
+ ```
8310
+
8311
+ The counts (`x21`) are how many times each edge was seen; they depend on how long the loop ran.
8312
+
8313
+ ## See the loop in Redux DevTools
8314
+
8315
+ `devtools: true` sends each finding to the extension as a `cycle/detected` action. Put `reduxDevtools` next to it to also see the emits:
8316
+
8317
+ ```js
8318
+ // devtools-bus.js
8319
+ import { createBus } from "nanoeffects";
8320
+ import { createTracer, graphLogger, reduxDevtools, tracePlugin } from "nanoeffects/trace";
8321
+
8322
+ // devtools: true — one cycle/detected action for each loop, with the path
8323
+ const graph = graphLogger({ name: "app", devtools: true });
8324
+
8325
+ // reduxDevtools — the emits of the bus as actions
8326
+ const trace = createTracer([reduxDevtools({ name: "app" }), graph]);
8327
+
8328
+ export const bus = createBus({ trace }).use(tracePlugin());
8329
+ ```
8330
+
8331
+ Redux DevTools is a browser extension, so open the page in its own tab and pick the instance in its list. Without the extension nothing is sent and nothing throws. You can try it with the buttons of [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
8332
+
8333
+ ## Find a loop that no chain shows
8334
+
8335
+ Some loops have no chain of emits:
8336
+
8337
+ - **A component that emits on every render.** `useEmitter` and a `bus.emit` in an effect run outside any handler, so each emit is a root. `graphLogger` counts such emits for each event, and reports a **storm** when one event comes 100 times in a second. Change the limit with `storm: { count, perMs }`, or turn it off with `storm: false`.
8338
+ - **A `bus.emit` inside a handler.** It starts a new chain, so the handler that calls it again does not look like a loop. Use `ctx.emit` inside handlers to keep the chain.
8339
+ - **A chain of generated names** (`step/${n}`) never repeats a name. `graphLogger` reports a **runaway** chain when one root starts more than `maxSteps` tasks (5000 by default).
8340
+
8341
+ ```js
8342
+ // storm.js
8343
+ import { graphLogger } from "nanoeffects/trace";
8344
+
8345
+ export const graph = graphLogger({
8346
+ storm: { count: 50, perMs: 1000 }, // 50 emits of one event in a second from outside
8347
+ maxSteps: 2000, // 2000 tasks started by one root
8348
+ ignore: ["tree/visit"], // a recursion on purpose is not counted
8349
+ });
8350
+ ```
8351
+
8352
+ ## Break the loop
8353
+
8354
+ | Shape | Fix |
8355
+ | ----- | --- |
8356
+ | A handler emits its own event | Emit a different event for the result (`user/loaded`, not `user/load`). |
8357
+ | Two handlers feed each other | Add the condition that ends it, or send the result to a third event that nothing in the loop listens to. |
8358
+ | A job whose outcome has the type of its trigger | Give the outcomes their own types. |
8359
+ | A component emits on every render | Pass `equals` to `useEmitter`, so the same value is not an emit, or `debounce` the handler. |
8360
+ | A failure re-emits the event that failed | Stop after a number of tries with `retry`, not with an event. |
8361
+
8362
+ ## Cut a loop at run time
8363
+
8364
+ The report does not stop a loop. For a loop that must not take the page down, add [`loopGuardPlugin`](loop-guard.md). It refuses the emit that would pass 50 repeats, or a chain of 1000 emits, and reports it:
8365
+
8366
+ ```js
8367
+ // guarded-bus.js
8368
+ import { createBus } from "nanoeffects";
8369
+ import { loopGuardPlugin } from "nanoeffects/guard";
8370
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8371
+
8372
+ const graph = graphLogger({ name: "app" });
8373
+ const trace = createTracer(graph);
8374
+
8375
+ // loopGuardPlugin — cuts the loop that the graph reports
8376
+ export const bus = createBus({ trace })
8377
+ .use(tracePlugin())
8378
+ .use(loopGuardPlugin({ maxRepeats: 50 }));
8379
+ ```
8380
+
8381
+ The guard is independent of the logger: use either one alone.
8382
+
8383
+ ## Fail a test on a loop
8384
+
8385
+ Run a flow of the app and check that the graph has no cycle. A loop that someone adds later fails the build.
8386
+
8387
+ ```js
8388
+ // bus.js
8389
+ import { createBus } from "nanoeffects";
8390
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8391
+
8392
+ // the bus.js of "Find a loop": the graph logger on the tracer of the bus
8393
+ export const graph = graphLogger({ name: "cart" });
8394
+ export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
8395
+ ```
8396
+
8397
+ ```js
8398
+ // cart-handlers.js
8399
+ import { bus } from "./bus";
8400
+
8401
+ bus.on("checkout/started", (_order, ctx) => {
8402
+ ctx.emit("checkout/paid");
8403
+ });
8404
+ ```
8405
+
8406
+ ```js
8407
+ // cycles.test.js
8408
+ import { expect, test } from "vitest";
8409
+ import { bus, graph } from "./bus";
8410
+ import "./cart-handlers";
8411
+
8412
+ test("the checkout flow has no event cycles", () => {
8413
+ // run the flow the way a user would
8414
+ bus.emit("checkout/started");
8415
+
8416
+ // cycles() — empty when the graph has no loop
8417
+ expect(graph.cycles()).toEqual([]);
8418
+ });
8419
+ ```
8420
+
8421
+ ## Export the graph and the payloads
8422
+
8423
+ The exports leave payloads out, because a payload can hold private data. Ask for them when you need them:
8424
+
8425
+ ```js
8426
+ // bus.js
8427
+ import { createBus } from "nanoeffects";
8428
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
8429
+
8430
+ // the bus.js of "Find a loop": the graph logger on the tracer of the bus
8431
+ export const graph = graphLogger({ name: "cart" });
8432
+ export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
8433
+ ```
8434
+
8435
+ ```js
8436
+ // payloads.js
8437
+ import { graph } from "./bus";
8438
+
8439
+ // payload: true — adds the last payload and error of each node, made safe
8440
+ const data = graph.toJSON({ payload: true });
8441
+
8442
+ console.log(JSON.stringify(data));
8443
+ ```
8444
+
8445
+ A payload can be a class instance, a `Map`, a circular object or an `Error`, which a plain `JSON.stringify` cannot take. The exports make them plain JSON: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ "$map": [...] }`, and an instance of a class keeps its own fields and its name as `$class`. Getters are never called, and a value that is too deep or too big is cut.
8446
+
8447
+ ## Know the limits
8448
+
8449
+ ### Code that repeats on purpose looks like a loop
8450
+
8451
+ `graphLogger` calls it a loop when one event stands in a chain three times. Some code does that by design. A handler that walks a tree emits `tree/visit` for every child, so the same event is in the chain once for each level. The logger cannot tell this from a mistake, so it reports it.
8452
+
8453
+ Tell it with `ignore`. The event stays in the graph and in the exports, but it is not counted as a repeat, a step, or a storm:
8454
+
8455
+ ```js
8456
+ // graph.js
8457
+ import { graphLogger } from "nanoeffects/trace";
8458
+
8459
+ export const graph = graphLogger({
8460
+ // tree/visit repeats once for each level of the tree, on purpose
8461
+ ignore: ["tree/visit"],
8462
+ });
8463
+ ```
8464
+
8465
+ Do not list an event only to silence a real loop: the report is the point. `ignore` also takes a function of the name, `ignore: (name) => name.startsWith("tree/")`.
8466
+
8467
+ ### A loop is reported once, and keeps being counted
8468
+
8469
+ A loop that runs 500 turns writes one warning, not 500. After the warning, the logger counts the turns quietly. Read the count from `graph.cycles()` to see whether the loop is still going:
8470
+
8471
+ ```js
8472
+ // turns.js
8473
+ import { graph } from "./graph";
8474
+
8475
+ const [loop] = graph.cycles();
8476
+
8477
+ // turns — how many times the loop has gone round since it was found, the first time included
8478
+ console.log(loop.path.join(" → "), loop.turns);
8479
+ ```
8480
+
8481
+ Read it twice a moment apart. A number that grew means the loop is still running. The same number means it stopped. To act on the first report in code, use `onCycle`; it is called once too.
8482
+
8483
+ ### Without tracePlugin the graph is coarser
8484
+
8485
+ Without `tracePlugin` the logger sees handlers but not the emits that nobody listens to, and it cannot give a name to a listener. A loop is still found, by event type.
8486
+
8487
+ ---
8488
+ Source: docs/loop-guard.md
8489
+
8490
+ # Cut a loop with `loopGuardPlugin`
8491
+
8492
+ > **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).
8493
+
8494
+ 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.
8495
+
8496
+ 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.
8497
+
8498
+ ## On this page
8499
+
8500
+ - [Cut a loop](#cut-a-loop)
8501
+ - [Choose what happens at the limit](#choose-what-happens-at-the-limit)
8502
+ - [Limit the emits that come from outside](#limit-the-emits-that-come-from-outside)
8503
+ - [Leave a recursion out](#leave-a-recursion-out)
8504
+ - [Know what it does not do](#know-what-it-does-not-do)
8505
+
8506
+ ## Cut a loop
8507
+
8508
+ 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.
8509
+
8510
+ ```js
8511
+ // bus.js
8512
+ import { createBus } from "nanoeffects";
8513
+ import { loopGuardPlugin } from "nanoeffects/guard";
8514
+
8515
+ // loopGuardPlugin — refuses the emit that would pass a limit of a chain
8516
+ export const bus = createBus().use(
8517
+ loopGuardPlugin({
8518
+ maxRepeats: 50, // one event may be in a chain 50 times
8519
+ maxDepth: 1000, // a chain may be 1000 emits long, whatever the names are
8520
+ }),
8521
+ );
8522
+ ```
8523
+
8524
+ | Option | Default | Cuts |
8525
+ | ------ | ------- | ---- |
8526
+ | `maxRepeats` | 50 | The same event in one chain too often: a loop. |
8527
+ | `maxDepth` | 1000 | A chain that is too long, also when every event has a new name (`step/${n}`). |
8528
+ | `maxPerSecond` | none | Emits of one event from outside any handler that come too fast ([below](#limit-the-emits-that-come-from-outside)). |
8529
+ | `ignore` | none | Event names that the repeat count skips ([below](#leave-a-recursion-out)). |
8530
+
8531
+ ## Choose what happens at the limit
8532
+
8533
+ `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.
8534
+
8535
+ ```js
8536
+ // bus.js
8537
+ import { createBus } from "nanoeffects";
8538
+ import { LoopError, loopGuardPlugin } from "nanoeffects/guard";
8539
+
8540
+ export const bus = createBus().use(
8541
+ loopGuardPlugin({
8542
+ onLimit: "drop", // "drop" (default): the emit is not sent; "throw": it throws a LoopError
8543
+ // report — replaces the default console.error
8544
+ report: (finding) => console.error(finding.limit, finding.path.join(" → ")),
8545
+ }),
8546
+ );
8547
+
8548
+ // with onLimit: "throw", the emit that was refused throws, and the error names the path
8549
+ try {
8550
+ bus.emit("cart/changed");
8551
+ } catch (error) {
8552
+ if (error instanceof LoopError) {
8553
+ console.log(error.limit, error.path);
8554
+ }
8555
+ }
8556
+ ```
8557
+
8558
+ - **`"drop"`** is the default. The emit returns as if nobody listened, so no new error appears in an async handler.
8559
+ - **`"throw"`** shows the loop with a stack. In an async handler the error becomes a rejection: handle it with `onError`.
8560
+
8561
+ 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.
8562
+
8563
+ ## Limit the emits that come from outside
8564
+
8565
+ A component that emits on every render has no chain: each emit comes from outside any handler. `maxPerSecond` counts them for each event.
8566
+
8567
+ ```js
8568
+ // bus.js
8569
+ import { createBus } from "nanoeffects";
8570
+ import { loopGuardPlugin } from "nanoeffects/guard";
8571
+
8572
+ // maxPerSecond — over 100 emits of one event in a second from outside are refused
8573
+ export const bus = createBus().use(loopGuardPlugin({ maxPerSecond: 100 }));
8574
+ ```
8575
+
8576
+ It is off by default, because a burst of many emits can be right (a stream of messages).
8577
+
8578
+ ## Leave a recursion out
8579
+
8580
+ 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.
8581
+
8582
+ ```js
8583
+ // bus.js
8584
+ import { createBus } from "nanoeffects";
8585
+ import { loopGuardPlugin } from "nanoeffects/guard";
8586
+
8587
+ export const bus = createBus().use(
8588
+ loopGuardPlugin({
8589
+ ignore: ["tree/visit"], // names, or a function of the name
8590
+ }),
8591
+ );
8592
+ ```
8593
+
8594
+ ## Know what it does not do
8595
+
8596
+ - **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.
8597
+ - **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.
8598
+ - **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)).
8599
+
6967
8600
  ---
6968
8601
  Source: docs/observable.md
6969
8602
 
6970
8603
  # Turn events into Observables
6971
8604
 
8605
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
8606
+
6972
8607
  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.
6973
8608
 
6974
8609
  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).
@@ -7204,6 +8839,8 @@ Source: docs/hmr.md
7204
8839
 
7205
8840
  # Hot reload and development diagnostics
7206
8841
 
8842
+ > **Level F.** Read first: [Actions, jobs and modules](actions.md).
8843
+
7207
8844
  Use this page in development with modules and hot reload. Skip until you mount domains with `modulePlugin`.
7208
8845
 
7209
8846
  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:
@@ -7241,11 +8878,43 @@ bus.use(devPlugin());
7241
8878
 
7242
8879
  - **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.
7243
8880
  - **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.
7244
- - **Options.** `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
8881
+ - **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.
8882
+ - **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)
8883
+ - **Options.** `abandoned: false`, `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
7245
8884
  - **Disconnect.** `bus.use(devPlugin(...))` returns the function that stops the diagnostics and puts `bus.on` and `app.mount` back.
7246
8885
 
7247
8886
  It changes no behavior: it wraps `bus.on`, `app.mount`, and `app.unmount` and adds messages.
7248
8887
 
8888
+ ### Find a handler that ignores the signal
8889
+
8890
+ 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.
8891
+
8892
+ ```js
8893
+ import { createBus } from "nanoeffects";
8894
+ import { latest } from "nanoeffects/policies";
8895
+
8896
+ const bus = createBus();
8897
+
8898
+ bus.on(
8899
+ "search",
8900
+ latest(async (query, ctx) => {
8901
+ // the signal is not passed, so an older request is not cancelled
8902
+ const items = await fetch(`/api?q=${query}`).then((response) => response.json());
8903
+ ctx.emit("results", items);
8904
+ }),
8905
+ );
8906
+ ```
8907
+
8908
+ 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:
8909
+
8910
+ ```text
8911
+ [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, ...)).
8912
+ ```
8913
+
8914
+ 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.
8915
+
8916
+ 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.
8917
+
7249
8918
  ### Keep the import in production
7250
8919
 
7251
8920
  `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.