nanoeffects 0.29.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 (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 +44 -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 +2 -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 +321 -9
  54. package/docs/debounce-and-abort.md +3 -1
  55. package/docs/debugging-loops.md +283 -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 +281 -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 +401 -0
  83. package/docs/ru/debounce-and-abort.md +105 -0
  84. package/docs/ru/debugging-loops.md +283 -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 +281 -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 +2008 -440
  123. package/llms.txt +11 -8
  124. package/package.json +22 -5
  125. package/dist/context-DtUnzC-1.js +0 -1
  126. package/docs/scope.md +0 -278
  127. package/docs/stability.md +0 -90
@@ -0,0 +1,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,5 +1,7 @@
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
 
5
7
  ## On this page
@@ -19,6 +21,7 @@ Optional. A plugin adds one thing to a bus: fields on `ctx`, a method, a registr
19
21
  - [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
20
22
  - [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
21
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)
22
25
  - [Put them together](#put-them-together)
23
26
  - [Use them in any order](#use-them-in-any-order)
24
27
  - [See what is not a plugin](#see-what-is-not-a-plugin)
@@ -47,6 +50,7 @@ Find your need in the left column. No row fits: you need no plugin.
47
50
  | A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
48
51
  | Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
49
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) |
50
54
 
51
55
  ## Main plugins
52
56
 
@@ -56,7 +60,7 @@ These four cover what most apps need: shared dependencies, effects, handlers and
56
60
  | ------ | ----------------- | ------ | ----------------- |
57
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 |
58
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 |
59
- | [`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 }` |
60
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 }` |
61
65
 
62
66
  ### Put a store or a client on every handler: `contextPlugin`
@@ -77,7 +81,7 @@ A client that must stop with the task, such as an `axios` instance, goes in as `
77
81
 
78
82
  ### Use `race` and `delay` without `ctx`: `effectsPlugin`
79
83
 
80
- 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).
81
85
 
82
86
  ```js
83
87
  import { createBus } from "nanoeffects";
@@ -116,7 +120,7 @@ const user = await bus.emit(getUser("1")); // { id: "1" }
116
120
 
117
121
  ### Mount a domain as one unit: `modulePlugin`
118
122
 
119
- 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).
120
124
 
121
125
  ```js
122
126
  import { createBus } from "nanoeffects";
@@ -143,10 +147,11 @@ Add one of these when you meet its need. None of them is required.
143
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 |
144
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` |
145
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 |
146
151
 
147
152
  ### Give one action its own fields: `scopePlugin`
148
153
 
149
- 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).
150
155
 
151
156
  ```js
152
157
  import { createBus } from "nanoeffects";
@@ -174,7 +179,7 @@ import { createBus } from "nanoeffects";
174
179
  import { createAction } from "nanoeffects/actions";
175
180
  import { signalPlugin } from "nanoeffects/signal";
176
181
 
177
- // signalPlugin — emit takes { signal } for an action, a job or an action
182
+ // signalPlugin — emit takes { signal } for an action or a job
178
183
  const bus = createBus().use(signalPlugin());
179
184
 
180
185
  const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
@@ -291,6 +296,18 @@ const disconnect = bus.use(devPlugin());
291
296
  disconnect(); // stops the diagnostics
292
297
  ```
293
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
+
294
311
  ## Put them together
295
312
 
296
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.
package/docs/policies.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # Policies
2
2
 
3
+ > **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4
+
3
5
  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.
4
6
 
5
7
  Start with `latest`. The table and sections below are the rest of the reference.
6
8
 
7
- ## Abort the previous run
9
+ ## `latest`: abort the previous run
8
10
 
9
11
  ```js
10
12
  import { createBus } from "nanoeffects";
@@ -26,7 +28,7 @@ Pass `signal` to `fetch` (and the like) so the request cancels.
26
28
 
27
29
  ## Choose a policy
28
30
 
29
- Every policy accepts `key` in its options (see [Isolate state by key](#isolate-state-by-key)).
31
+ Every policy accepts `key` in its options (see [`key`: isolate state by key](#key-isolate-state-by-key)).
30
32
 
31
33
  | Policy | Overlapping calls |
32
34
  | ------ | ----------------- |
@@ -36,54 +38,79 @@ Every policy accepts `key` in its options (see [Isolate state by key](#isolate-s
36
38
  | `throttle(ms, fn, options?)` | at most one run per `ms` |
37
39
  | `queue(fn, options?)` | one after another, in order |
38
40
 
39
- ## Call a policy without its function
41
+ Each policy has a short entry in [Look up a policy](#look-up-a-policy).
40
42
 
41
- **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.
43
+ ## Look up a policy
42
44
 
43
- **When.** You pass a policy to `usePolicy`. With a bus, `withPolicy` or your own code, call the policy with its function as above.
45
+ One entry per policy: what it does, and a link to the full recipe.
44
46
 
45
- 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:
47
+ ### `latest`
46
48
 
47
- ```js
48
- import { debounce } from "nanoeffects/policies";
49
+ A newer call aborts the older one and drops its result.
49
50
 
50
- const runSearch = async (query) => {
51
- await api.search(query);
52
- };
51
+ Signature: `latest(fn, options?)`.
53
52
 
54
- // debounce(300) — no function yet: returns a wrapper
55
- const wrap = debounce(300);
53
+ More: [`latest`: abort the previous run](#latest-abort-the-previous-run).
56
54
 
57
- // the wrapper takes the function; same as debounce(300, runSearch)
58
- const search = wrap(runSearch);
59
- ```
55
+ ### `leading`
56
+
57
+ New calls are dropped while a run is going.
58
+
59
+ Signature: `leading(fn, options?)`.
60
+
61
+ More: [Prevent a double submit](prevent-double-submit.md).
62
+
63
+ ### `debounce`
64
+
65
+ Runs after a period of quiet.
66
+
67
+ Signature: `debounce(ms, fn, options?)`.
68
+
69
+ More: [Configure debounce and throttle](#configure-debounce-and-throttle).
70
+
71
+ ### `throttle`
72
+
73
+ At most one run within `ms`.
60
74
 
61
- - 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 })`.
62
- - Each time you apply a wrapper to a function, you get a new policy with its own state.
63
- - To combine policies, nest them as before: `throttle(100, latest(fn))`.
64
- - 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.
75
+ Signature: `throttle(ms, fn, options?)`.
76
+
77
+ More: [Configure debounce and throttle](#configure-debounce-and-throttle).
78
+
79
+ ### `queue`
80
+
81
+ Builds a queue of calls: they run one after another, in order.
82
+
83
+ Signature: `queue(fn, options?)`.
84
+
85
+ More: [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue).
86
+
87
+ ### `key`
88
+
89
+ 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.
90
+
91
+ Signature: `latest(fn, { key: (payload) => payload.id })`.
92
+
93
+ More: [`key`: isolate state by key](#key-isolate-state-by-key).
94
+
95
+ 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).
65
96
 
66
97
  ## Configure debounce and throttle
67
98
 
68
- `debounce` and `throttle` take `leading` and `trailing`. Behavior matches lodash (and patronum); tests compare random call sequences to lodash itself.
99
+ `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.
69
100
 
70
- **Throttle** (`throttle(ms, fn)`):
101
+ **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`.
71
102
 
72
- - Default `leading: true`, `trailing: true`.
73
- - A call when the throttle is idle (at least `ms` after the previous call and run) runs at once.
74
- - Calls made while it waits are held; the latest runs when the window closes.
75
- - Under a steady stream it runs every `ms`.
76
- - `leading: false` — even the first call waits for the end of its window.
77
- - `trailing: false` — drop calls made during a window.
103
+ - `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.
104
+ - `trailing` — run the last held call when the window closes. It is `true` by default. With `false`, calls made during a window are dropped.
78
105
 
79
- **Debounce** (`debounce(ms, fn)`):
106
+ **Debounce** (`debounce(ms, fn)`) runs the last call of a series after `ms` of quiet.
80
107
 
81
- - Default `trailing: true` — run the last call after `ms` of quiet.
82
- - `leading: true` — also run the first call of a burst at once; the trailing run happens only if more calls follow.
83
- - `leading: true, trailing: false` — only the first call of a burst.
108
+ - `trailing` — run the last call after `ms` of quiet. It is `true` by default.
109
+ - `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.
110
+ - `leading: true, trailing: false` — only the first call of a series.
84
111
  - Both `false` — nothing runs.
85
112
 
86
- ## Isolate state by key
113
+ ## `key`: isolate state by key
87
114
 
88
115
  `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).
89
116
 
@@ -142,35 +169,35 @@ Every call returns a promise with the result of its own run. An error rejects on
142
169
  - When a running call aborts, its slot is freed at once and its result is dropped.
143
170
  - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
144
171
 
145
- ## Use a policy without a bus
172
+ ## `withPolicy`: call a policy outside a bus
146
173
 
147
- **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.
174
+ **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.
148
175
 
149
- **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.
176
+ **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.
150
177
 
151
- `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.
178
+ `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.
152
179
 
153
180
  ```js
154
181
  import { debounce, withPolicy } from "nanoeffects/policies";
155
182
 
183
+ const input = document.querySelector("#search");
184
+
156
185
  // debounce — waits for 300 ms of quiet, then runs the callback
157
186
  const policy = debounce(300, async (query, { signal }) => {
158
- await api.search(query, { signal });
187
+ const items = await api.search(query, { signal });
188
+ render(items);
159
189
  });
160
190
 
161
191
  // withPolicy — a plain function: no bus, no context argument
162
- const onChange = withPolicy(policy);
192
+ const onInput = withPolicy(policy);
163
193
 
164
- onChange("re");
165
- onChange("redux"); // only this call runs, 300 ms after it
194
+ input.addEventListener("input", (event) => onInput(event.target.value));
166
195
 
167
- // cancel — drops a pending call and aborts a running one
168
- onChange.cancel();
196
+ // cancel — drops a pending call and aborts a running request, for example when the page closes
197
+ window.addEventListener("pagehide", () => onInput.cancel());
169
198
  ```
170
199
 
171
200
  - 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.
172
201
  - Every call returns a promise with the result. A call the policy drops resolves with `undefined`.
173
202
  - `cancel()` aborts every call that has not finished. Calls made after it work again.
174
- - Where `Symbol.dispose` exists, `using onChange = withPolicy(policy)` calls `cancel()` at the end of the scope.
175
-
176
- In a React component, use [`usePolicy`](react.md#keep-a-debounced-handler-between-renders) instead: it keeps the function between renders.
203
+ - Where `Symbol.dispose` exists, `using onInput = withPolicy(policy)` calls `cancel()` at the end of the scope.
@@ -1,5 +1,7 @@
1
1
  # Prevent a double submit
2
2
 
3
+ > **Level B.** Read first: [Policies](policies.md).
4
+
3
5
  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.
4
6
 
5
7
  ## See the problem
@@ -94,7 +96,7 @@ bus.on(
94
96
  );
95
97
  ```
96
98
 
97
- Backoff between tries: [Back off between retries](effects.md#back-off-between-retries).
99
+ Backoff between tries: [`backoff`](effects.md#backoff).
98
100
 
99
101
  ## Guard a handler when you have no bus
100
102
 
@@ -1,5 +1,7 @@
1
1
  # Use nanoeffects with React Query, SWR and RTK Query
2
2
 
3
+ > **Level E.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
4
+
3
5
  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.
4
6
 
5
7
  You need a React project, `nanoeffects`, and one of the libraries. `api` below is your own client: replace it with your requests.
@@ -126,7 +128,7 @@ import { createAction } from "nanoeffects/actions";
126
128
  export const userLoaded = createAction("user/loaded");
127
129
  ```
128
130
 
129
- 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).
131
+ 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).
130
132
 
131
133
  ## Call it from RTK Query
132
134