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.
- package/README.md +6 -5
- package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
- package/dist/context-B5Z2RpJt.js +1 -0
- package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +49 -3
- package/dist/dev.js +2 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/emit-key-C6FL7Ewn.js +1 -0
- package/dist/guard.d.ts +59 -0
- package/dist/guard.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
- package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
- package/dist/notrace/actions.d.ts +2 -2
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +1 -1
- package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/oop.js +1 -1
- package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/policies.js +1 -1
- package/dist/production/dev.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +22 -2
- package/dist/signal.js +1 -1
- package/dist/site-DLvUoXs3.js +1 -0
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +151 -2
- package/dist/trace.js +3 -1
- package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
- package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
- package/docs/README.md +62 -31
- package/docs/actions.md +44 -27
- package/docs/cancel-stale-requests.md +2 -0
- package/docs/chat-example.md +3 -1
- package/docs/chat-widget.md +2 -0
- package/docs/context.md +322 -9
- package/docs/debounce-and-abort.md +3 -1
- package/docs/debugging-loops.md +318 -0
- package/docs/effects.md +255 -138
- package/docs/errors.md +4 -2
- package/docs/examples.md +60 -0
- package/docs/guide.md +39 -22
- package/docs/hmr.md +35 -1
- package/docs/loop-guard.md +109 -0
- package/docs/migrating-from-effector.md +4 -2
- package/docs/migrating-from-mitt.md +6 -4
- package/docs/migrating-from-rxjs.md +2 -0
- package/docs/migrating-from-saga.md +2 -0
- package/docs/motivation.md +14 -21
- package/docs/observable.md +2 -0
- package/docs/oop.md +3 -1
- package/docs/package.md +4 -1
- package/docs/pass-the-signal.md +346 -0
- package/docs/plugins.md +22 -5
- package/docs/policies.md +73 -46
- package/docs/prevent-double-submit.md +3 -1
- package/docs/query-libraries.md +3 -1
- package/docs/react.md +42 -13
- package/docs/recipes.md +528 -0
- package/docs/ru/README.md +62 -0
- package/docs/ru/actions.md +412 -0
- package/docs/ru/cancel-stale-requests.md +106 -0
- package/docs/ru/chat-example.md +259 -0
- package/docs/ru/chat-widget.md +131 -0
- package/docs/ru/context.md +402 -0
- package/docs/ru/debounce-and-abort.md +105 -0
- package/docs/ru/debugging-loops.md +318 -0
- package/docs/ru/effects.md +538 -0
- package/docs/ru/errors.md +36 -0
- package/docs/ru/examples.md +60 -0
- package/docs/ru/guide.md +530 -0
- package/docs/ru/hmr.md +83 -0
- package/docs/ru/loop-guard.md +109 -0
- package/docs/ru/migrating-from-effector.md +339 -0
- package/docs/ru/migrating-from-mitt.md +99 -0
- package/docs/ru/migrating-from-rxjs.md +197 -0
- package/docs/ru/migrating-from-saga.md +97 -0
- package/docs/ru/motivation.md +47 -0
- package/docs/ru/observable.md +233 -0
- package/docs/ru/oop.md +431 -0
- package/docs/ru/package.md +42 -0
- package/docs/ru/pass-the-signal.md +346 -0
- package/docs/ru/plugins.md +398 -0
- package/docs/ru/policies.md +201 -0
- package/docs/ru/prevent-double-submit.md +122 -0
- package/docs/ru/query-libraries.md +392 -0
- package/docs/ru/react.md +309 -0
- package/docs/ru/recipes.md +528 -0
- package/docs/ru/semantics.md +19 -0
- package/docs/ru/server.md +204 -0
- package/docs/ru/starter.md +118 -0
- package/docs/ru/stores.md +673 -0
- package/docs/ru/svelte.md +78 -0
- package/docs/ru/tracing.md +466 -0
- package/docs/ru/typescript.md +170 -0
- package/docs/ru/vue.md +92 -0
- package/docs/semantics.md +2 -0
- package/docs/server.md +2 -0
- package/docs/starter.md +2 -0
- package/docs/stores.md +2 -0
- package/docs/svelte.md +3 -1
- package/docs/tracing.md +58 -1
- package/docs/typescript.md +2 -0
- package/docs/vue.md +3 -1
- package/llms-full.txt +2087 -418
- package/llms.txt +11 -8
- package/package.json +23 -6
- package/dist/context-DtUnzC-1.js +0 -1
- package/docs/scope.md +0 -278
- package/docs/stability.md +0 -90
|
@@ -0,0 +1,346 @@
|
|
|
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
|
+
- [Bind the signal once](#bind-the-signal-once)
|
|
11
|
+
- [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
|
|
12
|
+
- [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
|
|
13
|
+
- [Turn on the checks](#turn-on-the-checks)
|
|
14
|
+
- [Choose which calls are checked](#choose-which-calls-are-checked)
|
|
15
|
+
- [Count lost signals in a test](#count-lost-signals-in-a-test)
|
|
16
|
+
- [Prove it with a test](#prove-it-with-a-test)
|
|
17
|
+
- [Know what the checks cannot see](#know-what-the-checks-cannot-see)
|
|
18
|
+
|
|
19
|
+
## Know what the signal does
|
|
20
|
+
|
|
21
|
+
`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.
|
|
22
|
+
|
|
23
|
+
## Bind the signal once
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
// bus.js
|
|
29
|
+
import { createBus } from "nanoeffects";
|
|
30
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
31
|
+
import { fromSignal, signalFetch } from "nanoeffects/signal";
|
|
32
|
+
import axios from "axios";
|
|
33
|
+
|
|
34
|
+
// fromSignal — ctx.fetch is made from the signal of the task that reads it
|
|
35
|
+
export const bus = createBus().use(
|
|
36
|
+
contextPlugin({
|
|
37
|
+
fetch: fromSignal(signalFetch),
|
|
38
|
+
// your own client too: it is made from the signal of each task
|
|
39
|
+
http: fromSignal((signal) => axios.create({ signal })),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
// search-handlers.js
|
|
46
|
+
import { latest } from "nanoeffects/policies";
|
|
47
|
+
import { bus } from "./bus";
|
|
48
|
+
|
|
49
|
+
bus.on(
|
|
50
|
+
"search",
|
|
51
|
+
latest(async (query, ctx) => {
|
|
52
|
+
// no signal here: ctx.fetch stops when the policy cancels the handler
|
|
53
|
+
const response = await ctx.fetch(`/api?q=${query}`);
|
|
54
|
+
ctx.emit("results", await response.json());
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
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)).
|
|
60
|
+
|
|
61
|
+
## Fix a handler that loses the signal
|
|
62
|
+
|
|
63
|
+
The two shapes you meet first, and the fix:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import { createBus } from "nanoeffects";
|
|
67
|
+
import { latest } from "nanoeffects/policies";
|
|
68
|
+
|
|
69
|
+
const bus = createBus();
|
|
70
|
+
|
|
71
|
+
// 1. ctx is taken and the signal is lost: the request cannot stop
|
|
72
|
+
bus.on(
|
|
73
|
+
"search",
|
|
74
|
+
latest(async (query, ctx) => {
|
|
75
|
+
const items = await api.search(query);
|
|
76
|
+
ctx.emit("results", items);
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// 2. one step passes it and another does not: the first one cannot stop
|
|
81
|
+
bus.on(
|
|
82
|
+
"feed",
|
|
83
|
+
latest(async (query, ctx) => {
|
|
84
|
+
const news = await api.news(query);
|
|
85
|
+
const posts = await api.posts(query, { signal: ctx.signal });
|
|
86
|
+
ctx.emit("results", { news, posts });
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// the fix: every step that waits gets the signal
|
|
91
|
+
bus.on(
|
|
92
|
+
"feed/fixed",
|
|
93
|
+
latest(async (query, ctx) => {
|
|
94
|
+
const news = await api.news(query, { signal: ctx.signal });
|
|
95
|
+
const posts = await api.posts(query, { signal: ctx.signal });
|
|
96
|
+
ctx.emit("results", { news, posts });
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Pass the signal when it is hard
|
|
102
|
+
|
|
103
|
+
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)).
|
|
104
|
+
|
|
105
|
+
| Situation | What to do | Lint | devPlugin |
|
|
106
|
+
| --------- | ---------- | :--: | :-------: |
|
|
107
|
+
| The handler has no `ctx`: `latest(async (query) => ...)` | Take `(query, ctx)` and pass `ctx.signal` | yes | |
|
|
108
|
+
| `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 |
|
|
109
|
+
| One step passes the signal, another does not | Pass it to every step | yes | yes |
|
|
110
|
+
| The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
|
|
111
|
+
| Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
|
|
112
|
+
| `Promise.all(ids.map((id) => load(id)))` | Pass it inside the callback | yes | |
|
|
113
|
+
| 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 | |
|
|
114
|
+
| `timeout(ctx, 1000, (child) => api.search(q))` | Pass `child`, or `child.signal`, to the request | yes | |
|
|
115
|
+
| `ctx.run((child) => api.search(q))` | Pass `child` | yes | |
|
|
116
|
+
| A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
|
|
117
|
+
| A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
|
|
118
|
+
| A chain: `api.news(q).then(...)` | Pass the signal to the first call | yes | |
|
|
119
|
+
| 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 | |
|
|
120
|
+
| Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
|
|
121
|
+
| 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 | |
|
|
122
|
+
| 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 | |
|
|
123
|
+
| The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
|
|
124
|
+
| `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
|
|
125
|
+
| A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx.fetch` | | yes |
|
|
126
|
+
| Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
|
|
127
|
+
| `ctx.on` after the cancel | The same | | yes |
|
|
128
|
+
|
|
129
|
+
The pause and the effect rows in code:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
import { createBus } from "nanoeffects";
|
|
133
|
+
import { delay, retry, timeout } from "nanoeffects/effects";
|
|
134
|
+
import { latest } from "nanoeffects/policies";
|
|
135
|
+
|
|
136
|
+
const bus = createBus();
|
|
137
|
+
|
|
138
|
+
bus.on(
|
|
139
|
+
"search",
|
|
140
|
+
latest(async (query, ctx) => {
|
|
141
|
+
// delay — a pause that stops with ctx, not new Promise(setTimeout)
|
|
142
|
+
await delay(ctx, 300);
|
|
143
|
+
|
|
144
|
+
// retry — the attempts stop with ctx, but the request needs the signal too
|
|
145
|
+
const items = await retry(ctx, () => api.search(query, { signal: ctx.signal }));
|
|
146
|
+
|
|
147
|
+
// timeout — the callback gets a child context: give it to the request
|
|
148
|
+
const details = await timeout(ctx, 5000, (child) => api.details(query, child));
|
|
149
|
+
|
|
150
|
+
ctx.emit("results", { items, details });
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Calls that run together, and a signal that joins another:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
import { createBus } from "nanoeffects";
|
|
159
|
+
import { latest } from "nanoeffects/policies";
|
|
160
|
+
|
|
161
|
+
const bus = createBus();
|
|
162
|
+
|
|
163
|
+
bus.on(
|
|
164
|
+
"feed",
|
|
165
|
+
latest(async (query, ctx) => {
|
|
166
|
+
// each call of the group gets the signal, not only the first
|
|
167
|
+
const [news, posts] = await Promise.all([
|
|
168
|
+
api.news(query, { signal: ctx.signal }),
|
|
169
|
+
api.posts(query, { signal: ctx.signal }),
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
// AbortSignal.any — stops with the handler or after 5 seconds, whichever comes first
|
|
173
|
+
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)]);
|
|
174
|
+
const details = await api.details(query, { signal });
|
|
175
|
+
|
|
176
|
+
ctx.emit("results", { news, posts, details });
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Turn on the checks
|
|
182
|
+
|
|
183
|
+
**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:
|
|
184
|
+
|
|
185
|
+
<!-- docs-check: skip - a config file of the reader's project -->
|
|
186
|
+
```js
|
|
187
|
+
// eslint.config.mjs
|
|
188
|
+
import nanoeffects from "./nanoeffects-lint.mjs";
|
|
189
|
+
|
|
190
|
+
export default [
|
|
191
|
+
{
|
|
192
|
+
files: ["**/*.{js,mjs,cjs,jsx,ts,tsx}"],
|
|
193
|
+
plugins: { nanoeffects },
|
|
194
|
+
rules: {
|
|
195
|
+
"nanoeffects/policy-handler-uses-signal": [
|
|
196
|
+
"warn",
|
|
197
|
+
// calls that take no signal and are fine to wait for
|
|
198
|
+
{ ignore: ["cache.get", "storage.*"], requireContext: false },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
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.
|
|
206
|
+
|
|
207
|
+
**While you run.** `devPlugin` warns once when a handler emits, or subscribes with `ctx.on`, after its task was cancelled:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
import { createBus } from "nanoeffects";
|
|
211
|
+
import { devPlugin } from "nanoeffects/dev";
|
|
212
|
+
|
|
213
|
+
const bus = createBus();
|
|
214
|
+
|
|
215
|
+
// devPlugin — warns about a handler that kept running after it was cancelled; nothing in production
|
|
216
|
+
bus.use(devPlugin());
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Choose which calls are checked
|
|
220
|
+
|
|
221
|
+
The rule cannot tell, from the call alone, whether the function takes a signal. Ways to tell it:
|
|
222
|
+
|
|
223
|
+
- **`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.
|
|
224
|
+
- **`ignore`** lists the calls to skip, by name: `ignore: ["cache.get", "storage.*"]`. Use it when most calls take a signal and a few cannot.
|
|
225
|
+
- **`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.
|
|
226
|
+
- **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.
|
|
227
|
+
|
|
228
|
+
<!-- docs-check: skip - a config file of the reader's project -->
|
|
229
|
+
```js
|
|
230
|
+
// eslint.config.mjs
|
|
231
|
+
import parser from "@typescript-eslint/parser";
|
|
232
|
+
import nanoeffects from "./nanoeffects-lint.mjs";
|
|
233
|
+
|
|
234
|
+
export default [
|
|
235
|
+
{
|
|
236
|
+
files: ["**/*.{ts,tsx}"],
|
|
237
|
+
// the parser gives the rule the type checker of your project
|
|
238
|
+
languageOptions: { parser, parserOptions: { project: "./tsconfig.json" } },
|
|
239
|
+
plugins: { nanoeffects },
|
|
240
|
+
rules: { "nanoeffects/policy-handler-uses-signal": "warn" },
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
## Count lost signals in a test
|
|
248
|
+
|
|
249
|
+
`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:
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
import { createBus } from "nanoeffects";
|
|
253
|
+
import { devPlugin, getLostSignalsCount, getLostSignals } from "nanoeffects/dev";
|
|
254
|
+
import { latest } from "nanoeffects/policies";
|
|
255
|
+
import { expect, test } from "vitest";
|
|
256
|
+
|
|
257
|
+
test("a newer search leaves no lost signals", async () => {
|
|
258
|
+
const bus = createBus();
|
|
259
|
+
// devPlugin — records every emit that a cancelled handler made; log hides its warnings in the test
|
|
260
|
+
bus.use(devPlugin({ log: () => {} }));
|
|
261
|
+
bus.on(
|
|
262
|
+
"search",
|
|
263
|
+
latest(async (query, ctx) => {
|
|
264
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
265
|
+
ctx.emit("results", items);
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
bus.emit("search", "a");
|
|
270
|
+
bus.emit("search", "ab"); // cancels the first call
|
|
271
|
+
|
|
272
|
+
// wait — how long to let the handlers finish before counting, in ms
|
|
273
|
+
const count = await getLostSignalsCount(bus, { wait: 50 });
|
|
274
|
+
|
|
275
|
+
expect(count).toBe(0);
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`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.
|
|
280
|
+
|
|
281
|
+
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.
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
import { createBus } from "nanoeffects";
|
|
285
|
+
import { devPlugin, getLostSignals } from "nanoeffects/dev";
|
|
286
|
+
import { latest } from "nanoeffects/policies";
|
|
287
|
+
|
|
288
|
+
const bus = createBus();
|
|
289
|
+
bus.use(devPlugin({ log: () => {} }));
|
|
290
|
+
|
|
291
|
+
bus.on(
|
|
292
|
+
"search",
|
|
293
|
+
latest(async (query) => {
|
|
294
|
+
await fetch(`/api?q=${query}`); // no signal
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
bus.emit("search", "a");
|
|
299
|
+
bus.emit("search", "ab");
|
|
300
|
+
|
|
301
|
+
// [{ kind: "request", handler: "search", event: "/api?q=a" }]
|
|
302
|
+
console.log(await getLostSignals(bus, { wait: 50 }));
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
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`.
|
|
306
|
+
|
|
307
|
+
## Prove it with a test
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
<!-- docs-check: skip - a test file of the reader's project -->
|
|
312
|
+
```js
|
|
313
|
+
// search.cancel.test.js
|
|
314
|
+
import { createBus } from "nanoeffects";
|
|
315
|
+
import { latest } from "nanoeffects/policies";
|
|
316
|
+
import { expect, test } from "vitest";
|
|
317
|
+
// copy prove-cancel.js from examples/lint; it needs no package
|
|
318
|
+
import { requestsThatKeptRunning } from "./prove-cancel.js";
|
|
319
|
+
|
|
320
|
+
test("a newer search stops the older one at every step", async () => {
|
|
321
|
+
const bus = createBus();
|
|
322
|
+
bus.on(
|
|
323
|
+
"search",
|
|
324
|
+
latest(async (query, ctx) => {
|
|
325
|
+
const news = await api.news(query, { signal: ctx.signal });
|
|
326
|
+
const posts = await api.posts(query, { signal: ctx.signal });
|
|
327
|
+
ctx.emit("results", { news, posts });
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// for each step: start a call, cancel it with a newer one, and collect the requests that did not stop
|
|
332
|
+
const kept = await requestsThatKeptRunning((value) => bus.emit("search", value));
|
|
333
|
+
|
|
334
|
+
// empty when every request got a signal that aborted with the handler
|
|
335
|
+
expect(kept).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`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.
|
|
340
|
+
|
|
341
|
+
## Know what the checks cannot see
|
|
342
|
+
|
|
343
|
+
- **A handler that is not written in place.** `latest(searchHandler)` and `latest(withLogging(async (query, ctx) => ...))` are not read by the linter.
|
|
344
|
+
- **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.
|
|
345
|
+
- **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.
|
|
346
|
+
- **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`](#
|
|
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#
|
|
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#
|
|
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](
|
|
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
|
|
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
|
-
##
|
|
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 [
|
|
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
|
-
|
|
41
|
+
Each policy has a short entry in [Look up a policy](#look-up-a-policy).
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
## Look up a policy
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
One entry per policy: what it does, and a link to the full recipe.
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
### `latest`
|
|
46
48
|
|
|
47
|
-
|
|
48
|
-
import { debounce } from "nanoeffects/policies";
|
|
49
|
+
A newer call aborts the older one and drops its result.
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
await api.search(query);
|
|
52
|
-
};
|
|
51
|
+
Signature: `latest(fn, options?)`.
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
const wrap = debounce(300);
|
|
53
|
+
More: [`latest`: abort the previous run](#latest-abort-the-previous-run).
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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`.
|
|
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
|
-
-
|
|
73
|
-
-
|
|
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
|
-
-
|
|
82
|
-
- `leading
|
|
83
|
-
- `leading: true, trailing: false` — only the first call of a
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
|
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
|
|
192
|
+
const onInput = withPolicy(policy);
|
|
163
193
|
|
|
164
|
-
|
|
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
|
|
168
|
-
|
|
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
|
|
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: [
|
|
99
|
+
Backoff between tries: [`backoff`](effects.md#backoff).
|
|
98
100
|
|
|
99
101
|
## Guard a handler when you have no bus
|
|
100
102
|
|
package/docs/query-libraries.md
CHANGED
|
@@ -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#
|
|
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
|
|