nanoeffects 0.28.0 → 0.30.1

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 (139) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-6rlBTmH9.d.ts} +60 -33
  3. package/dist/actions.d.ts +18 -18
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-DFlmNJ6h.js +1 -0
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/context-types-DKNc5mkS.d.ts +31 -0
  8. package/dist/context.d.ts +8 -3
  9. package/dist/context.js +1 -1
  10. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  11. package/dist/dev.d.ts +44 -3
  12. package/dist/dev.js +2 -1
  13. package/dist/effects.d.ts +2 -2
  14. package/dist/effects.js +1 -1
  15. package/dist/emit-key-C6FL7Ewn.js +1 -0
  16. package/dist/guard.d.ts +59 -0
  17. package/dist/guard.js +1 -0
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +1 -1
  20. package/dist/match.d.ts +3 -3
  21. package/dist/match.js +1 -1
  22. package/dist/{module-C_oEXw1n.d.ts → module-G39MaVOt.d.ts} +38 -38
  23. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-BrMSO3RP.d.ts} +35 -32
  24. package/dist/notrace/actions.d.ts +52 -52
  25. package/dist/notrace/actions.js +1 -1
  26. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  27. package/dist/notrace/effects.d.ts +2 -2
  28. package/dist/notrace/index.d.ts +2 -2
  29. package/dist/notrace/index.js +1 -1
  30. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  31. package/dist/notrace/policies.d.ts +1 -1
  32. package/dist/notrace/redux.d.ts +1 -1
  33. package/dist/observable.d.ts +43 -1
  34. package/dist/observable.js +1 -1
  35. package/dist/onAbort-BgMfspK4.js +1 -0
  36. package/dist/oop.d.ts +3 -3
  37. package/dist/oop.js +1 -1
  38. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  39. package/dist/policies.d.ts +2 -2
  40. package/dist/policies.js +1 -1
  41. package/dist/production/dev.js +1 -1
  42. package/dist/react.d.ts +2 -2
  43. package/dist/react.js +1 -1
  44. package/dist/redux.d.ts +1 -1
  45. package/dist/scope.d.ts +9 -9
  46. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  47. package/dist/signal.d.ts +34 -6
  48. package/dist/signal.js +1 -1
  49. package/dist/site-DLvUoXs3.js +1 -0
  50. package/dist/svelte.d.ts +1 -1
  51. package/dist/trace.d.ts +170 -6
  52. package/dist/trace.js +3 -1
  53. package/dist/{types-FV8fuvI9.d.ts → types-N_cdRr1r.d.ts} +2 -2
  54. package/dist/vue.d.ts +1 -1
  55. package/dist/{with-policy-D-SQRkPh.js → with-policy-CeM1rtES.js} +1 -1
  56. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  57. package/docs/README.md +65 -22
  58. package/docs/actions.md +56 -13
  59. package/docs/cancel-stale-requests.md +2 -0
  60. package/docs/chat-example.md +15 -1
  61. package/docs/chat-widget.md +9 -7
  62. package/docs/context.md +352 -8
  63. package/docs/debounce-and-abort.md +3 -1
  64. package/docs/debugging-loops.md +283 -0
  65. package/docs/effects.md +259 -131
  66. package/docs/errors.md +4 -2
  67. package/docs/examples.md +60 -0
  68. package/docs/guide.md +57 -22
  69. package/docs/hmr.md +35 -1
  70. package/docs/loop-guard.md +109 -0
  71. package/docs/migrating-from-effector.md +15 -2
  72. package/docs/migrating-from-mitt.md +6 -4
  73. package/docs/migrating-from-rxjs.md +2 -0
  74. package/docs/migrating-from-saga.md +2 -0
  75. package/docs/motivation.md +14 -21
  76. package/docs/observable.md +45 -0
  77. package/docs/oop.md +20 -1
  78. package/docs/package.md +25 -13
  79. package/docs/pass-the-signal.md +281 -0
  80. package/docs/plugins.md +90 -15
  81. package/docs/policies.md +73 -46
  82. package/docs/prevent-double-submit.md +3 -1
  83. package/docs/query-libraries.md +276 -6
  84. package/docs/react.md +50 -11
  85. package/docs/recipes.md +528 -0
  86. package/docs/ru/README.md +62 -0
  87. package/docs/ru/actions.md +412 -0
  88. package/docs/ru/cancel-stale-requests.md +106 -0
  89. package/docs/ru/chat-example.md +259 -0
  90. package/docs/ru/chat-widget.md +131 -0
  91. package/docs/ru/context.md +401 -0
  92. package/docs/ru/debounce-and-abort.md +105 -0
  93. package/docs/ru/debugging-loops.md +283 -0
  94. package/docs/ru/effects.md +538 -0
  95. package/docs/ru/errors.md +36 -0
  96. package/docs/ru/examples.md +60 -0
  97. package/docs/ru/guide.md +530 -0
  98. package/docs/ru/hmr.md +83 -0
  99. package/docs/ru/loop-guard.md +109 -0
  100. package/docs/ru/migrating-from-effector.md +339 -0
  101. package/docs/ru/migrating-from-mitt.md +99 -0
  102. package/docs/ru/migrating-from-rxjs.md +197 -0
  103. package/docs/ru/migrating-from-saga.md +97 -0
  104. package/docs/ru/motivation.md +47 -0
  105. package/docs/ru/observable.md +233 -0
  106. package/docs/ru/oop.md +431 -0
  107. package/docs/ru/package.md +42 -0
  108. package/docs/ru/pass-the-signal.md +281 -0
  109. package/docs/ru/plugins.md +398 -0
  110. package/docs/ru/policies.md +201 -0
  111. package/docs/ru/prevent-double-submit.md +122 -0
  112. package/docs/ru/query-libraries.md +392 -0
  113. package/docs/ru/react.md +309 -0
  114. package/docs/ru/recipes.md +528 -0
  115. package/docs/ru/semantics.md +19 -0
  116. package/docs/ru/server.md +204 -0
  117. package/docs/ru/starter.md +118 -0
  118. package/docs/ru/stores.md +673 -0
  119. package/docs/ru/svelte.md +78 -0
  120. package/docs/ru/tracing.md +466 -0
  121. package/docs/ru/typescript.md +170 -0
  122. package/docs/ru/vue.md +92 -0
  123. package/docs/semantics.md +12 -0
  124. package/docs/server.md +5 -3
  125. package/docs/starter.md +2 -0
  126. package/docs/stores.md +2 -0
  127. package/docs/svelte.md +3 -1
  128. package/docs/tracing.md +132 -7
  129. package/docs/typescript.md +3 -1
  130. package/docs/vue.md +3 -1
  131. package/llms-full.txt +2718 -545
  132. package/llms.txt +17 -14
  133. package/package.json +25 -8
  134. package/skills/nanoeffects/SKILL.md +2 -2
  135. package/dist/bus-DlpEluzd.js +0 -1
  136. package/dist/context-UcmrAIhP.js +0 -1
  137. package/dist/context-types-hCKgU8sC.d.ts +0 -14
  138. package/docs/scope.md +0 -266
  139. package/docs/stability.md +0 -90
package/docs/package.md CHANGED
@@ -1,25 +1,37 @@
1
1
  # Choose a package entry
2
2
 
3
+ > **Levels A to F.** Read first: [README](../README.md).
4
+
3
5
  Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
4
6
 
7
+ ## Start with these three
8
+
5
9
  | Import | What is in it |
6
10
  | ------ | ------------- |
7
11
  | `nanoeffects` | `createBus`, the types |
8
12
  | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
9
13
  | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
10
- | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` |
11
- | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events |
12
- | `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value |
13
- | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` |
14
- | `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` |
15
- | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) |
16
- | `nanoeffects/redux` | `createEffectsMiddleware` |
17
- | `nanoeffects/observable` | `createObserve`, `Subject` |
18
- | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
19
- | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) |
20
- | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
21
- | `nanoeffects/dev` | `devPlugin` (no-op in production) |
22
- | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
14
+
15
+ ## Add the rest when you need it
16
+
17
+ Each row is optional. The "when" column says what need brings you to it.
18
+
19
+ | Import | What is in it | When |
20
+ | ------ | ------------- | ---- |
21
+ | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
22
+ | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
23
+ | `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value | a store or client must be on every task |
24
+ | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` | an outside `AbortSignal` (request, React Query) must stop tasks |
25
+ | `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` | you write classes, not functions |
26
+ | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
27
+ | `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
28
+ | `nanoeffects/observable` | `createObserve`, `Subject` | you need an Observable (RxJS) |
29
+ | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
30
+ | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
31
+ | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
32
+ | `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
33
+ | `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops) and `tracePlugin` (names for the trace) | you want to see what ran and why |
34
+ | `nanoeffects/guard` | `loopGuardPlugin`: refuses the emit that would pass a limit of a chain | a loop must not take the app down |
23
35
 
24
36
  The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists all of them, what each returns, and the order to use them in.
25
37
 
@@ -0,0 +1,281 @@
1
+ # Keep the signal through a handler
2
+
3
+ > **Level B.** Read first: [Policies](policies.md#latest).
4
+
5
+ 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.
6
+
7
+ ## On this page
8
+
9
+ - [Know what the signal does](#know-what-the-signal-does)
10
+ - [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
11
+ - [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
12
+ - [Turn on the checks](#turn-on-the-checks)
13
+ - [Choose which calls are checked](#choose-which-calls-are-checked)
14
+ - [Count lost signals in a test](#count-lost-signals-in-a-test)
15
+ - [Prove it with a test](#prove-it-with-a-test)
16
+ - [Know what the checks cannot see](#know-what-the-checks-cannot-see)
17
+
18
+ ## Know what the signal does
19
+
20
+ `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.
21
+
22
+ ## Fix a handler that loses the signal
23
+
24
+ The two shapes you meet first, and the fix:
25
+
26
+ ```js
27
+ import { createBus } from "nanoeffects";
28
+ import { latest } from "nanoeffects/policies";
29
+
30
+ const bus = createBus();
31
+
32
+ // 1. ctx is taken and the signal is lost: the request cannot stop
33
+ bus.on(
34
+ "search",
35
+ latest(async (query, ctx) => {
36
+ const items = await api.search(query);
37
+ ctx.emit("results", items);
38
+ }),
39
+ );
40
+
41
+ // 2. one step passes it and another does not: the first one cannot stop
42
+ bus.on(
43
+ "feed",
44
+ latest(async (query, ctx) => {
45
+ const news = await api.news(query);
46
+ const posts = await api.posts(query, { signal: ctx.signal });
47
+ ctx.emit("results", { news, posts });
48
+ }),
49
+ );
50
+
51
+ // the fix: every step that waits gets the signal
52
+ bus.on(
53
+ "feed/fixed",
54
+ latest(async (query, ctx) => {
55
+ const news = await api.news(query, { signal: ctx.signal });
56
+ const posts = await api.posts(query, { signal: ctx.signal });
57
+ ctx.emit("results", { news, posts });
58
+ }),
59
+ );
60
+ ```
61
+
62
+ ## Pass the signal when it is hard
63
+
64
+ 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)).
65
+
66
+ | Situation | What to do | Lint | devPlugin |
67
+ | --------- | ---------- | :--: | :-------: |
68
+ | The handler has no `ctx`: `latest(async (query) => ...)` | Take `(query, ctx)` and pass `ctx.signal` | yes | |
69
+ | `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request | yes | yes |
70
+ | One step passes the signal, another does not | Pass it to every step | yes | yes |
71
+ | The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
72
+ | Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
73
+ | `Promise.all(ids.map((id) => load(id)))` | Pass it inside the callback | yes | |
74
+ | 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 | |
75
+ | `timeout(ctx, 1000, (child) => api.search(q))` | Pass `child`, or `child.signal`, to the request | yes | |
76
+ | `ctx.run((child) => api.search(q))` | Pass `child` | yes | |
77
+ | A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
78
+ | A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
79
+ | A chain: `api.news(q).then(...)` | Pass the signal to the first call | yes | |
80
+ | 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 | |
81
+ | Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
82
+ | 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 | |
83
+ | 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 | |
84
+ | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
85
+ | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
86
+ | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
87
+ | `ctx.on` after the cancel | The same | | yes |
88
+
89
+ The pause and the effect rows in code:
90
+
91
+ ```js
92
+ import { createBus } from "nanoeffects";
93
+ import { delay, retry, timeout } from "nanoeffects/effects";
94
+ import { latest } from "nanoeffects/policies";
95
+
96
+ const bus = createBus();
97
+
98
+ bus.on(
99
+ "search",
100
+ latest(async (query, ctx) => {
101
+ // delay — a pause that stops with ctx, not new Promise(setTimeout)
102
+ await delay(ctx, 300);
103
+
104
+ // retry — the attempts stop with ctx, but the request needs the signal too
105
+ const items = await retry(ctx, () => api.search(query, { signal: ctx.signal }));
106
+
107
+ // timeout — the callback gets a child context: give it to the request
108
+ const details = await timeout(ctx, 5000, (child) => api.details(query, child));
109
+
110
+ ctx.emit("results", { items, details });
111
+ }),
112
+ );
113
+ ```
114
+
115
+ Calls that run together, and a signal that joins another:
116
+
117
+ ```js
118
+ import { createBus } from "nanoeffects";
119
+ import { latest } from "nanoeffects/policies";
120
+
121
+ const bus = createBus();
122
+
123
+ bus.on(
124
+ "feed",
125
+ latest(async (query, ctx) => {
126
+ // each call of the group gets the signal, not only the first
127
+ const [news, posts] = await Promise.all([
128
+ api.news(query, { signal: ctx.signal }),
129
+ api.posts(query, { signal: ctx.signal }),
130
+ ]);
131
+
132
+ // AbortSignal.any — stops with the handler or after 5 seconds, whichever comes first
133
+ const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)]);
134
+ const details = await api.details(query, { signal });
135
+
136
+ ctx.emit("results", { news, posts, details });
137
+ }),
138
+ );
139
+ ```
140
+
141
+ ## Turn on the checks
142
+
143
+ **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:
144
+
145
+ <!-- docs-check: skip - a config file of the reader's project -->
146
+ ```js
147
+ // eslint.config.mjs
148
+ import nanoeffects from "./nanoeffects-lint.mjs";
149
+
150
+ export default [
151
+ {
152
+ files: ["**/*.{js,mjs,cjs,jsx,ts,tsx}"],
153
+ plugins: { nanoeffects },
154
+ rules: {
155
+ "nanoeffects/policy-handler-uses-signal": [
156
+ "warn",
157
+ // calls that take no signal and are fine to wait for
158
+ { ignore: ["cache.get", "storage.*"] },
159
+ ],
160
+ },
161
+ },
162
+ ];
163
+ ```
164
+
165
+ 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.
166
+
167
+ **While you run.** `devPlugin` warns once when a handler emits, or subscribes with `ctx.on`, after its task was cancelled:
168
+
169
+ ```js
170
+ import { createBus } from "nanoeffects";
171
+ import { devPlugin } from "nanoeffects/dev";
172
+
173
+ const bus = createBus();
174
+
175
+ // devPlugin — warns about a handler that kept running after it was cancelled; nothing in production
176
+ bus.use(devPlugin());
177
+ ```
178
+
179
+ ## Choose which calls are checked
180
+
181
+ The rule cannot tell, from the call alone, whether the function takes a signal. Three ways to tell it:
182
+
183
+ - **`ignore`** lists the calls to skip, by name: `ignore: ["cache.get", "storage.*"]`. Use it when most calls take a signal and a few cannot.
184
+ - **`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.
185
+ - **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.
186
+
187
+ <!-- docs-check: skip - a config file of the reader's project -->
188
+ ```js
189
+ // eslint.config.mjs
190
+ import parser from "@typescript-eslint/parser";
191
+ import nanoeffects from "./nanoeffects-lint.mjs";
192
+
193
+ export default [
194
+ {
195
+ files: ["**/*.{ts,tsx}"],
196
+ // the parser gives the rule the type checker of your project
197
+ languageOptions: { parser, parserOptions: { project: "./tsconfig.json" } },
198
+ plugins: { nanoeffects },
199
+ rules: { "nanoeffects/policy-handler-uses-signal": "warn" },
200
+ },
201
+ ];
202
+ ```
203
+
204
+ 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.
205
+
206
+ ## Count lost signals in a test
207
+
208
+ `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:
209
+
210
+ ```js
211
+ import { createBus } from "nanoeffects";
212
+ import { devPlugin, getLostSignalsCount, getLostSignals } from "nanoeffects/dev";
213
+ import { latest } from "nanoeffects/policies";
214
+ import { expect, test } from "vitest";
215
+
216
+ test("a newer search leaves no lost signals", async () => {
217
+ const bus = createBus();
218
+ // devPlugin — records every emit that a cancelled handler made; log hides its warnings in the test
219
+ bus.use(devPlugin({ log: () => {} }));
220
+ bus.on(
221
+ "search",
222
+ latest(async (query, ctx) => {
223
+ const items = await api.search(query, { signal: ctx.signal });
224
+ ctx.emit("results", items);
225
+ }),
226
+ );
227
+
228
+ bus.emit("search", "a");
229
+ bus.emit("search", "ab"); // cancels the first call
230
+
231
+ // wait — how long to let the handlers finish before counting, in ms
232
+ const count = await getLostSignalsCount(bus, { wait: 50 });
233
+
234
+ expect(count).toBe(0);
235
+ });
236
+ ```
237
+
238
+ `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.
239
+
240
+ It counts what a handler does after the cancel. A request that a handler started and that the cancel did not stop is not counted unless the handler then emits; the next section finds those.
241
+
242
+ ## Prove it with a test
243
+
244
+ 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.
245
+
246
+ <!-- docs-check: skip - a test file of the reader's project -->
247
+ ```js
248
+ // search.cancel.test.js
249
+ import { createBus } from "nanoeffects";
250
+ import { latest } from "nanoeffects/policies";
251
+ import { expect, test } from "vitest";
252
+ // copy prove-cancel.js from examples/lint; it needs no package
253
+ import { requestsThatKeptRunning } from "./prove-cancel.js";
254
+
255
+ test("a newer search stops the older one at every step", async () => {
256
+ const bus = createBus();
257
+ bus.on(
258
+ "search",
259
+ latest(async (query, ctx) => {
260
+ const news = await api.news(query, { signal: ctx.signal });
261
+ const posts = await api.posts(query, { signal: ctx.signal });
262
+ ctx.emit("results", { news, posts });
263
+ }),
264
+ );
265
+
266
+ // for each step: start a call, cancel it with a newer one, and collect the requests that did not stop
267
+ const kept = await requestsThatKeptRunning((value) => bus.emit("search", value));
268
+
269
+ // empty when every request got a signal that aborted with the handler
270
+ expect(kept).toEqual([]);
271
+ });
272
+ ```
273
+
274
+ `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.
275
+
276
+ ## Know what the checks cannot see
277
+
278
+ - **A handler that is not written in place.** `latest(searchHandler)` and `latest(withLogging(async (query, ctx) => ...))` are not read by the linter.
279
+ - **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.
280
+ - **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.
281
+ - **Shadowed names.** The rule uses no scope analysis, so an inner variable named `signal` can hide a lost signal.
package/docs/plugins.md CHANGED
@@ -1,13 +1,57 @@
1
1
  # Choose the plugins of a bus
2
2
 
3
+ > **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
4
+
3
5
  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.
4
6
 
7
+ ## On this page
8
+
9
+ - [Use a plugin](#use-a-plugin)
10
+ - [Pick a plugin by task](#pick-a-plugin-by-task)
11
+ - [Main plugins](#main-plugins)
12
+ - [Put a store or a client on every handler: `contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin)
13
+ - [Use `race` and `delay` without `ctx`: `effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin)
14
+ - [Bind or replace a handler: `actionsPlugin`](#bind-or-replace-a-handler-actionsplugin)
15
+ - [Mount a domain as one unit: `modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin)
16
+ - [Extra plugins](#extra-plugins)
17
+ - [Give one action its own fields: `scopePlugin`](#give-one-action-its-own-fields-scopeplugin)
18
+ - [Tie an emit or a listener to an outside signal: `signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin)
19
+ - [Name the origin and the flow of a trace: `tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin)
20
+ - [Listen to a group of events: `matchPlugin`](#listen-to-a-group-of-events-matchplugin)
21
+ - [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
22
+ - [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
23
+ - [Find leaks while you develop: `devPlugin`](#find-leaks-while-you-develop-devplugin)
24
+ - [Cut a loop that runs away: `loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin)
25
+ - [Put them together](#put-them-together)
26
+ - [Use them in any order](#use-them-in-any-order)
27
+ - [See what is not a plugin](#see-what-is-not-a-plugin)
28
+ - [Write your own plugin](#write-your-own-plugin)
29
+
5
30
  ## Use a plugin
6
31
 
7
32
  A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns. Most plugins return the same bus, so you can chain them: `createBus().use(a).use(b)`. A few return something of their own (a registry, an app, a `disconnect` function): keep it.
8
33
 
9
34
  In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
10
35
 
36
+ ## Pick a plugin by task
37
+
38
+ Find your need in the left column. No row fits: you need no plugin.
39
+
40
+ | I need | Use |
41
+ | ------ | --- |
42
+ | A store, client or logger on every handler | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) |
43
+ | `race`, `all` or `delay` without passing `ctx` | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) |
44
+ | A test that replaces a handler | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) |
45
+ | One domain mounted and unmounted as a unit | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) |
46
+ | Fields for one action or module only | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) |
47
+ | Tasks that stop when a request, socket or query is cancelled | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) |
48
+ | One listener for a group of events | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) |
49
+ | A callback for a state manager that emits | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) |
50
+ | A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
51
+ | Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
52
+ | Warnings about leaks while you develop | [`devPlugin`](#find-leaks-while-you-develop-devplugin) |
53
+ | A loop that must not take the app down | [`loopGuardPlugin`](#cut-a-loop-that-runs-away-loopguardplugin) |
54
+
11
55
  ## Main plugins
12
56
 
13
57
  These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
@@ -16,7 +60,7 @@ These four cover what most apps need: shared dependencies, effects, handlers and
16
60
  | ------ | ----------------- | ------ | ----------------- |
17
61
  | [`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 |
18
62
  | [`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 |
19
- | [`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 }` |
63
+ | [`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 }` |
20
64
  | [`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 }` |
21
65
 
22
66
  ### Put a store or a client on every handler: `contextPlugin`
@@ -33,9 +77,11 @@ const bus = createBus().use(contextPlugin({ store }));
33
77
  bus.on("save", (draft, { store }) => store.put(draft));
34
78
  ```
35
79
 
80
+ A client that must stop with the task, such as an `axios` instance, goes in as `fromSignal((signal) => axios.create({ signal }))` from `nanoeffects/signal`: it is built from the signal of each task, when the task reads it. More in [Build a field from the signal of each task](context.md#build-a-field-from-the-signal-of-each-task).
81
+
36
82
  ### Use `race` and `delay` without `ctx`: `effectsPlugin`
37
83
 
38
- 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).
84
+ 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).
39
85
 
40
86
  ```js
41
87
  import { createBus } from "nanoeffects";
@@ -48,7 +94,7 @@ const bus = createBus().use(effects);
48
94
 
49
95
  bus.on("load", async (id, { race, delay }) => {
50
96
  const result = await race({
51
- data: ({ signal }) => api.get(id, { signal }),
97
+ data: (child) => api.get(id, child),
52
98
  timeout: () => delay(5000),
53
99
  });
54
100
  });
@@ -74,7 +120,7 @@ const user = await bus.emit(getUser("1")); // { id: "1" }
74
120
 
75
121
  ### Mount a domain as one unit: `modulePlugin`
76
122
 
77
- 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).
123
+ 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).
78
124
 
79
125
  ```js
80
126
  import { createBus } from "nanoeffects";
@@ -85,7 +131,7 @@ const bus = createBus();
85
131
  // modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
86
132
  const app = bus.use(modulePlugin([userModule]));
87
133
 
88
- app.unmount(userModule); // its listeners, bound commands and tasks are gone
134
+ app.unmount(userModule); // its listeners, bound actions and tasks are gone
89
135
  ```
90
136
 
91
137
  ## Extra plugins
@@ -95,26 +141,27 @@ Add one of these when you meet its need. None of them is required.
95
141
  | Plugin | What it gives you | Import | `bus.use` returns |
96
142
  | ------ | ----------------- | ------ | ----------------- |
97
143
  | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) | Its own fields for one action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
98
- | [`signalPlugin`](#tie-an-emit-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
144
+ | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks, and `on(event, handler, { signal })` that removes the listener, when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
99
145
  | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) | `on` that listens to a group of events which a function picks. | `nanoeffects/match` | The same bus, typed with the new overloads |
100
146
  | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) | A callback that emits an action, to give to a state manager. | `nanoeffects/actions` | The same bus, with `emitter` |
101
147
  | [`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 |
102
148
  | [`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` |
103
149
  | [`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 |
150
+ | [`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 |
104
151
 
105
152
  ### Give one action its own fields: `scopePlugin`
106
153
 
107
- 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).
154
+ 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).
108
155
 
109
156
  ```js
110
157
  import { createBus } from "nanoeffects";
111
158
  import { createAction } from "nanoeffects/actions";
112
159
  import { scopePlugin } from "nanoeffects/scope";
113
160
 
114
- // scopePlugin — makes the scope of a command work on this bus
161
+ // scopePlugin — makes the scope of an action work on this bus
115
162
  const bus = createBus().use(scopePlugin());
116
163
 
117
- // the last argument: fields of this command only
164
+ // the last argument: fields of this action only
118
165
  const getUser = createAction("user/get", (id, { api }) => api.getUser(id), {
119
166
  api,
120
167
  });
@@ -123,25 +170,41 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
123
170
  bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
124
171
  ```
125
172
 
126
- ### Tie an emit to an outside signal: `signalPlugin`
173
+ ### Tie an emit or a listener to an outside signal: `signalPlugin`
127
174
 
128
- A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query and SWR](query-libraries.md).
175
+ A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
129
176
 
130
177
  ```js
131
178
  import { createBus } from "nanoeffects";
132
179
  import { createAction } from "nanoeffects/actions";
133
180
  import { signalPlugin } from "nanoeffects/signal";
134
181
 
135
- // signalPlugin — emit takes { signal } for a command, a job or an action
182
+ // signalPlugin — emit takes { signal } for an action or a job
136
183
  const bus = createBus().use(signalPlugin());
137
184
 
138
185
  const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
139
186
 
140
187
  const controller = new AbortController();
141
- bus.emit(getUser("1"), { signal: controller.signal }); // runs the command
188
+ bus.emit(getUser("1"), { signal: controller.signal }); // runs the action
142
189
  controller.abort(); // ctx.signal of the handler aborts with it
143
190
  ```
144
191
 
192
+ The same signal can end a listener. `on` and `once` take `{ signal }` as their options, as `addEventListener` does, so code that has a signal keeps no `unbind` function. The listener is removed when the signal aborts; a handler that already runs is not aborted. A signal that is aborted already adds no listener.
193
+
194
+ ```js
195
+ import { createBus } from "nanoeffects";
196
+ import { signalPlugin } from "nanoeffects/signal";
197
+
198
+ // signalPlugin — on and once take { signal } in their options
199
+ const bus = createBus().use(signalPlugin());
200
+
201
+ const controller = new AbortController();
202
+ bus.on("saved", (draft) => console.log(draft), { signal: controller.signal }); // listens until the abort
203
+ controller.abort(); // the listener is gone: bus.get("saved") is empty
204
+ ```
205
+
206
+ Without the plugin the `signal` option is ignored and the listener stays. `off(event, handler)` removes a listener but does not end its link to the signal; call the function that `on` returned for that.
207
+
145
208
  ### Name the origin and the flow of a trace: `tracePlugin`
146
209
 
147
210
  With a tracer on the bus, the plugin lets a trace say which subscription began a chain (`traceId` of `useEmitter` and `bus.emitter`), follow one flow by a `traceGroupId`, and show which listener ran (`bus.on(event, handler, { traceId })`). Without the plugin the options are ignored. More in [Tracing](tracing.md#show-which-subscription-began-a-chain).
@@ -233,6 +296,18 @@ const disconnect = bus.use(devPlugin());
233
296
  disconnect(); // stops the diagnostics
234
297
  ```
235
298
 
299
+ ### Cut a loop that runs away: `loopGuardPlugin`
300
+
301
+ 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).
302
+
303
+ ```js
304
+ import { createBus } from "nanoeffects";
305
+ import { loopGuardPlugin } from "nanoeffects/guard";
306
+
307
+ // loopGuardPlugin — refuses the emit that would put one event in a chain 50 times
308
+ const bus = createBus().use(loopGuardPlugin({ maxRepeats: 50 }));
309
+ ```
310
+
236
311
  ## Put them together
237
312
 
238
313
  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.
@@ -254,7 +329,7 @@ import { scopePlugin } from "nanoeffects/scope";
254
329
  export const bus = createBus()
255
330
  .use(contextPlugin({ store })) // ctx.store on every task
256
331
  .use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
257
- .use(scopePlugin()) // scopes for commands, actions, jobs, modules and emit
332
+ .use(scopePlugin()) // scopes for actions, jobs, modules and emit
258
333
  .use(matchPlugin()) // bus.on takes a function that picks the events
259
334
  .use(emitterPlugin()); // bus.emitter(creator)
260
335
 
@@ -295,7 +370,7 @@ These connect the bus to something, but are not used with `bus.use`:
295
370
  - `createEffectsMiddleware(bus, { filter })` from `nanoeffects/redux` is a Redux middleware for a bus that exists. See [Stores](stores.md#wire-redux-toolkit-to-a-bus).
296
371
  - `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
297
372
  - `extendBus(bus, Base)` from `nanoeffects/oop` gives a class the methods of a bus, with the subscriptions of each instance ended by `dispose()`. It reads the bus as it is, so use the plugins first. See [Use the bus from a class](oop.md).
298
- - `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable. See [Observable](observable.md).
373
+ - `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable, and `events(bus, event)` into a `for await` loop. See [Observable](observable.md).
299
374
 
300
375
  ## Write your own plugin
301
376