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.
- 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 +44 -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 +2 -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 +321 -9
- package/docs/debounce-and-abort.md +3 -1
- package/docs/debugging-loops.md +283 -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 +281 -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 +401 -0
- package/docs/ru/debounce-and-abort.md +105 -0
- package/docs/ru/debugging-loops.md +283 -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 +281 -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 +2008 -440
- package/llms.txt +11 -8
- package/package.json +22 -5
- package/dist/context-DtUnzC-1.js +0 -1
- package/docs/scope.md +0 -278
- package/docs/stability.md +0 -90
package/docs/context.md
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Working with context
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Level D.** Read first: [Guide](guide.md#1-create-a-bus).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Optional: put a store, client or logger on every task, or give fields to one action, module or call. Skip until many handlers need the same dependency.
|
|
6
|
+
|
|
7
|
+
## On this page
|
|
8
|
+
|
|
9
|
+
- [Context fields](#context-fields)
|
|
10
|
+
- [Pass values at runtime](#pass-values-at-runtime)
|
|
11
|
+
- [Type the fields (optional)](#type-the-fields-optional)
|
|
12
|
+
- [Build a field from the signal of each task](#build-a-field-from-the-signal-of-each-task)
|
|
13
|
+
- [Scope: fields for an action, a module and a call](#scope-fields-for-an-action-a-module-and-a-call)
|
|
14
|
+
- [Turn scopes on](#turn-scopes-on)
|
|
15
|
+
- [Give an action its own fields](#give-an-action-its-own-fields)
|
|
16
|
+
- [Bind an effect to the task](#bind-an-effect-to-the-task)
|
|
17
|
+
- [Replace a field for one call](#replace-a-field-for-one-call)
|
|
18
|
+
- [Give an action, a job or a module its own fields](#give-an-action-a-job-or-a-module-its-own-fields)
|
|
19
|
+
- [Which value wins](#which-value-wins)
|
|
20
|
+
- [Keep a generic effect generic](#keep-a-generic-effect-generic)
|
|
21
|
+
- [Type the fields of a scope](#type-the-fields-of-a-scope)
|
|
22
|
+
- [Limits](#limits)
|
|
23
|
+
|
|
24
|
+
## Context fields
|
|
25
|
+
|
|
26
|
+
### Pass values at runtime
|
|
27
|
+
|
|
28
|
+
<!-- docs-check: new -->
|
|
6
29
|
|
|
7
30
|
```js
|
|
8
31
|
import { createBus } from "nanoeffects";
|
|
@@ -15,15 +38,17 @@ bus.on("save", (draft, { store }) => {
|
|
|
15
38
|
});
|
|
16
39
|
```
|
|
17
40
|
|
|
18
|
-
That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, actions, modules, and child tasks of effects and policies.
|
|
41
|
+
Here `store` is your own object: a store, an API client, a logger, anything. That is enough in JavaScript. The fields appear on handlers, `ctx.run`, `ctx.on`, actions, modules, and child tasks of effects and policies.
|
|
19
42
|
|
|
20
43
|
Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
|
|
21
44
|
|
|
22
|
-
To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#
|
|
45
|
+
To get `race`, `all`, `take` and the like on every task without passing `ctx`, see [`effectsPlugin`](effects.md#effectsplugin).
|
|
46
|
+
|
|
47
|
+
To give fields to **one** action, job or module, or to replace them for one `emit`, see [scopes](context.md#scope-fields-for-an-action-a-module-and-a-call).
|
|
23
48
|
|
|
24
|
-
|
|
49
|
+
### Type the fields (optional)
|
|
25
50
|
|
|
26
|
-
|
|
51
|
+
<!-- docs-check: new -->
|
|
27
52
|
|
|
28
53
|
In TypeScript, `contextPlugin` (from `nanoeffects/context`) puts the fields on every task of **this** bus and takes their types from the value you pass. The event map holds only events. It does not wire anything across the app — another bus can have different fields or none.
|
|
29
54
|
|
|
@@ -45,7 +70,9 @@ bus.on("save", (draft, { store }) => store.put(draft));
|
|
|
45
70
|
- A name that the bus already has is a type error. The fields of the library (`signal`, `emit`, `run`, …) win at runtime, and a field that the context already has is not replaced.
|
|
46
71
|
- The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
|
|
47
72
|
|
|
48
|
-
|
|
73
|
+
### Build a field from the signal of each task
|
|
74
|
+
|
|
75
|
+
<!-- docs-check: new -->
|
|
49
76
|
|
|
50
77
|
Problem: an HTTP client or a database handle should stop when the task is aborted. Without help, every handler passes `ctx.signal` to it by hand, and one forgotten call leaves a request running.
|
|
51
78
|
|
|
@@ -77,7 +104,7 @@ bus.on("user/open", async (id, ctx) => {
|
|
|
77
104
|
- The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
|
|
78
105
|
- Without `contextPlugin` a `fromSignal` marker is only an object.
|
|
79
106
|
|
|
80
|
-
|
|
107
|
+
#### Name the fields in the event map instead
|
|
81
108
|
|
|
82
109
|
`createBus<Events>({ context: { store } })` also types the fields, but through the event map: `WithContext` is checker-only and says which fields the handlers of this bus have. Use it when you cannot call `use`, for example for the bus that `createEffectsMiddleware` makes.
|
|
83
110
|
|
|
@@ -89,3 +116,288 @@ type Events = { save: Draft } & WithContext<{ store: Store }>;
|
|
|
89
116
|
|
|
90
117
|
const bus = createBus<Events>({ context: { store } });
|
|
91
118
|
```
|
|
119
|
+
|
|
120
|
+
## Scope: fields for an action, a module and a call
|
|
121
|
+
|
|
122
|
+
Optional, for domain logic. Use it when one action needs its own client, one module its own logger, or one test its own fake, and a field on the whole bus ([context fields](context.md)) is too wide.
|
|
123
|
+
|
|
124
|
+
A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`, `logger`), except one: `$effects`. Its functions take `{ signal }` as the first parameter, like `retry(ctx, fn)`, and go on `ctx` bound to the task, so you call `ctx.fetchJson(url)` instead of `fetchJson(ctx, url)`.
|
|
125
|
+
|
|
126
|
+
You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
|
|
127
|
+
|
|
128
|
+
### Turn scopes on
|
|
129
|
+
|
|
130
|
+
<!-- docs-check: new -->
|
|
131
|
+
|
|
132
|
+
Scopes are a plugin, so a bus that does not use them pays nothing.
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
// bus.js
|
|
136
|
+
import { createBus } from "nanoeffects";
|
|
137
|
+
import { scopePlugin } from "nanoeffects/scope";
|
|
138
|
+
|
|
139
|
+
// scopePlugin — makes the scopes below work on this bus; returns the same bus
|
|
140
|
+
export const bus = createBus().use(scopePlugin());
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The bus is in one file and the actions in files named after their domain, so the next example imports `bus` from here. Each section after it keeps its own `bus.js` and domain file, as if it were an app of its own.
|
|
144
|
+
|
|
145
|
+
- Without `scopePlugin` a scope is **ignored**: no error and no warning. If a field is missing from `ctx`, check this first.
|
|
146
|
+
- Use it in any order with the other plugins: a scope wins over `effectsPlugin`, `contextPlugin` and `createBus({ context })`.
|
|
147
|
+
|
|
148
|
+
### Give an action its own fields
|
|
149
|
+
|
|
150
|
+
<!-- docs-check: new -->
|
|
151
|
+
|
|
152
|
+
<!-- docs-check: continue -->
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
// user.js
|
|
156
|
+
import { createAction } from "nanoeffects/actions";
|
|
157
|
+
|
|
158
|
+
// createAction — third argument is the scope of this action
|
|
159
|
+
export const getUser = createAction(
|
|
160
|
+
"user/get",
|
|
161
|
+
(id, { api, cache }) => {
|
|
162
|
+
// api and cache are on ctx for this action only
|
|
163
|
+
return cache.get(id) ?? api.getUser(id);
|
|
164
|
+
},
|
|
165
|
+
{ api, cache: new Map() },
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
import { bus } from "./bus";
|
|
171
|
+
import { getUser } from "./user";
|
|
172
|
+
|
|
173
|
+
const user = await bus.emit(getUser("1")); // the handler has api and cache
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The listeners of `"user/get"` get the same fields when the action is emitted, because the fields belong to the call.
|
|
177
|
+
|
|
178
|
+
### Bind an effect to the task
|
|
179
|
+
|
|
180
|
+
<!-- docs-check: new -->
|
|
181
|
+
|
|
182
|
+
An effect is a function with `{ signal }` as its first parameter. In `$effects` it is bound to the task, so it stops with the task.
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
// feed.js
|
|
186
|
+
import { createAction } from "nanoeffects/actions";
|
|
187
|
+
import { delay, race } from "nanoeffects/effects";
|
|
188
|
+
|
|
189
|
+
// your own effect: first parameter is the task (only its signal is used)
|
|
190
|
+
const fetchJson = (ctx, url) =>
|
|
191
|
+
fetch(url, { signal: ctx.signal }).then((response) => response.json());
|
|
192
|
+
|
|
193
|
+
export const loadFeed = createAction(
|
|
194
|
+
"feed/load",
|
|
195
|
+
(url, { fetchJson, race, delay }) =>
|
|
196
|
+
// race, delay, fetchJson — bound to this task: no ctx argument
|
|
197
|
+
race({
|
|
198
|
+
feed: () => fetchJson(url),
|
|
199
|
+
timeout: () => delay(5000),
|
|
200
|
+
}),
|
|
201
|
+
// $effects — built-in and your own functions share one object
|
|
202
|
+
{ $effects: { fetchJson, race, delay } },
|
|
203
|
+
);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
// bus.js
|
|
208
|
+
import { createBus } from "nanoeffects";
|
|
209
|
+
import { scopePlugin } from "nanoeffects/scope";
|
|
210
|
+
|
|
211
|
+
export const bus = createBus().use(scopePlugin());
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
import { bus } from "./bus";
|
|
216
|
+
import { loadFeed } from "./feed";
|
|
217
|
+
|
|
218
|
+
const result = await bus.emit(loadFeed("/feed"));
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- Put in `$effects` only functions that take `ctx` first. A plain function (a formatter, a logger) belongs in `context`.
|
|
222
|
+
- A function under any other key is copied as it is; a function in `$effects` is bound.
|
|
223
|
+
- `$effects` is the one name with a meaning. A field called `effects` is an ordinary field: `{ effects: { retries: 3 } }` puts that object on `ctx`, and nothing in it is bound.
|
|
224
|
+
|
|
225
|
+
### Replace a field for one call
|
|
226
|
+
|
|
227
|
+
<!-- docs-check: new -->
|
|
228
|
+
|
|
229
|
+
The second argument of `emit` is a scope for the tasks of **that** call. It is the usual way to give a test a fake.
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
// bus.js
|
|
233
|
+
import { createBus } from "nanoeffects";
|
|
234
|
+
import { scopePlugin } from "nanoeffects/scope";
|
|
235
|
+
|
|
236
|
+
export const bus = createBus().use(scopePlugin());
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
// user.js
|
|
241
|
+
import { createAction } from "nanoeffects/actions";
|
|
242
|
+
|
|
243
|
+
export const getUser = createAction(
|
|
244
|
+
"user/get",
|
|
245
|
+
(id, { api }) => api.getUser(id),
|
|
246
|
+
{ api },
|
|
247
|
+
);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
import { bus } from "./bus";
|
|
252
|
+
import { getUser } from "./user";
|
|
253
|
+
|
|
254
|
+
// the real api for this call
|
|
255
|
+
await bus.emit(getUser("1"));
|
|
256
|
+
|
|
257
|
+
// the second argument — a fake api, for this call only
|
|
258
|
+
const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
259
|
+
await bus.emit(getUser("1"), { api: fakeApi });
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Rules for the second argument:
|
|
263
|
+
|
|
264
|
+
- It works with the call of an action (`emit(getUser("1"), scope)`, `ctx.emit(loaded(user), scope)`).
|
|
265
|
+
- It does not work with the string form: in `emit("user/get", payload)` the second argument is the payload.
|
|
266
|
+
- It can replace `$effects` too: `{ $effects: { delay: instantDelay } }`.
|
|
267
|
+
- Fields of the library (`signal`, `emit`, …) in it are ignored.
|
|
268
|
+
|
|
269
|
+
### Give an action, a job or a module its own fields
|
|
270
|
+
|
|
271
|
+
<!-- docs-check: new -->
|
|
272
|
+
|
|
273
|
+
```js
|
|
274
|
+
// user.js
|
|
275
|
+
import { createAction, createJob } from "nanoeffects/actions";
|
|
276
|
+
|
|
277
|
+
// createAction — the listeners of this action's calls get the fields
|
|
278
|
+
export const userLoaded = createAction("user/loaded", { store });
|
|
279
|
+
|
|
280
|
+
// createJob — the scope is the fourth argument, after the outcomes;
|
|
281
|
+
// the handler and the listeners of the outcome get its fields
|
|
282
|
+
export const loadUser = createJob(
|
|
283
|
+
"user/load",
|
|
284
|
+
fetchUser,
|
|
285
|
+
{ success: userLoaded, fail: "user/failed" },
|
|
286
|
+
{ logger },
|
|
287
|
+
);
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
// audit-module.js
|
|
292
|
+
import { createModule } from "nanoeffects/actions";
|
|
293
|
+
import { userLoaded } from "./user";
|
|
294
|
+
|
|
295
|
+
// createModule — third argument; everything the module owns gets the fields:
|
|
296
|
+
// m.on listeners, m.handle handlers, m.spawn tasks
|
|
297
|
+
export const auditModule = createModule(
|
|
298
|
+
"audit",
|
|
299
|
+
(m) => {
|
|
300
|
+
m.on(userLoaded, (user, { logger }) => logger.info(user.id));
|
|
301
|
+
},
|
|
302
|
+
{ logger },
|
|
303
|
+
);
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
If the outcome action (`success` or `fail`) has a scope of its own, its listeners see the job's fields with the action's own fields over them; the handler of the job sees only the job's.
|
|
307
|
+
|
|
308
|
+
A module scope does not leak: a listener registered outside the module does not see it.
|
|
309
|
+
|
|
310
|
+
### Which value wins
|
|
311
|
+
|
|
312
|
+
<!-- docs-check: new -->
|
|
313
|
+
|
|
314
|
+
When several places set the same field, the strongest one wins:
|
|
315
|
+
|
|
316
|
+
| Strength | Where it is set |
|
|
317
|
+
| -------- | --------------- |
|
|
318
|
+
| 1 (strongest) | the second argument of `emit` |
|
|
319
|
+
| 2 | the scope of the action or job |
|
|
320
|
+
| 3 | the scope of the module that owns the handler |
|
|
321
|
+
| 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
|
|
322
|
+
|
|
323
|
+
The fields of the library (`signal`, `abort`, `emit`, `on`, `off`, `run`, `type`, `payload`) are never replaced. The name `traceGroupId` is reserved: the tracer reads it, and it is not put on `ctx` ([Follow one flow](tracing.md#follow-one-flow-with-a-tracegroupid)). A child task (`ctx.run`, an attempt of `retry`) has the fields of its parent.
|
|
324
|
+
|
|
325
|
+
### Keep a generic effect generic
|
|
326
|
+
|
|
327
|
+
<!-- docs-check: new -->
|
|
328
|
+
|
|
329
|
+
In `$effects` a function loses its first parameter, and TypeScript cannot keep a generic through that step: `<T>(items: T[]) => T` becomes `(items: unknown[]) => unknown`. JavaScript code is not affected. If you want the exact type, use one of two ways.
|
|
330
|
+
|
|
331
|
+
`effect(fn)`: mark the function where you declare it, then put it in the scope like any other value. No annotation, so a JavaScript editor shows the exact hint too.
|
|
332
|
+
|
|
333
|
+
```js
|
|
334
|
+
// pick.js
|
|
335
|
+
import { createAction } from "nanoeffects/actions";
|
|
336
|
+
import { effect } from "nanoeffects/scope";
|
|
337
|
+
|
|
338
|
+
// effect — marks the function, so a scope binds it to the task; the editor keeps its generic
|
|
339
|
+
const first = effect((ctx, items) => items[0]);
|
|
340
|
+
|
|
341
|
+
export const pick = createAction("pick", (items, { first }) => first(items), {
|
|
342
|
+
first, // a plain key of the scope, not an entry of `$effects`
|
|
343
|
+
});
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Call `effect` where you declare the function, as above, and not inline in the scope: TypeScript does not infer the scope from a generic call written there.
|
|
347
|
+
|
|
348
|
+
`Effect<F>` (TypeScript): declare how the effect looks **after** it is bound, and keep it in `$effects`.
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { createAction } from "nanoeffects/actions";
|
|
352
|
+
import type { Effect } from "nanoeffects/effects";
|
|
353
|
+
|
|
354
|
+
// First — checker-only: how a handler calls the effect (no ctx)
|
|
355
|
+
type First = <T>(items: T[]) => T;
|
|
356
|
+
|
|
357
|
+
// Effect<First> — the function takes the task first and is typed as First once bound
|
|
358
|
+
const first: Effect<First> = (ctx, items) => items[0];
|
|
359
|
+
|
|
360
|
+
const pick = createAction(
|
|
361
|
+
"pick",
|
|
362
|
+
(id: string, { first }) => first([id]), // string
|
|
363
|
+
{ $effects: { first } },
|
|
364
|
+
);
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`Effect<F>` and `effect` change what the checker sees. At run time the function is the same.
|
|
368
|
+
|
|
369
|
+
### Type the fields of a scope
|
|
370
|
+
|
|
371
|
+
<!-- docs-check: new -->
|
|
372
|
+
|
|
373
|
+
A handler of an action, job or module with a scope sees the fields of the scope in `ctx`, typed from the values you gave. You write no `WithContext`.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import { createAction } from "nanoeffects/actions";
|
|
377
|
+
|
|
378
|
+
type User = { id: string; name: string };
|
|
379
|
+
|
|
380
|
+
const getUser = createAction(
|
|
381
|
+
"user/get",
|
|
382
|
+
async (id: string, ctx) => {
|
|
383
|
+
ctx.cache; // Map<string, User>, from the scope below
|
|
384
|
+
return ctx.cache.get(id);
|
|
385
|
+
},
|
|
386
|
+
{ cache: new Map<string, User>() },
|
|
387
|
+
);
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
- A field of a scope replaces a field of the same name from the bus in the type too.
|
|
391
|
+
- An override in `emit` must match the type of the field it replaces: `emit(getUser("1"), { cache: 1 })` is an error.
|
|
392
|
+
- A field that no scope and no bus declares has no type to check against, so `emit(call, { unknown: 1 })` is an error in TypeScript.
|
|
393
|
+
- `bus.on(creator, fn)` for an action with a scope types `ctx` with its fields.
|
|
394
|
+
|
|
395
|
+
### Limits
|
|
396
|
+
|
|
397
|
+
<!-- docs-check: new -->
|
|
398
|
+
|
|
399
|
+
- No plugin, no scope: see [Turn scopes on](#turn-scopes-on).
|
|
400
|
+
- The fields of the bus are not in the handler of an action made with `createAction(…, scope)` unless you type `ctx` yourself; the fields of the scope are.
|
|
401
|
+
- The string form of `emit` takes no scope.
|
|
402
|
+
- `effect(fn)` works only as a value of a scope, not as an entry of `$effects`.
|
|
403
|
+
- A scope on an action, job or module is typed without the events of a bus, so `take` in its `$effects` takes any event name.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pause between requests
|
|
2
|
+
|
|
3
|
+
> **Level B.** Read first: [Policies](policies.md).
|
|
2
4
|
|
|
3
5
|
Read this page if you debounce user input (a search box, an autosave, a resize handler) and you also want the request in flight to stop when a new one starts. `debounce` alone waits for a pause. It does not cancel a request that already started. This page shows the two policies together, the order to put them in, and when to use `throttle` instead.
|
|
4
6
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Find and cut event loops
|
|
2
|
+
|
|
3
|
+
> **Level F.** Read first: [Trace tasks](tracing.md#attach-a-tracer). To only cut a loop, go to [`loopGuardPlugin`](loop-guard.md).
|
|
4
|
+
|
|
5
|
+
A loop is two handlers that emit each other's events, or one handler that emits its own. This page finds a loop, shows it, and stops it. Every scenario below is a runnable file in [`examples/debug`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug), and a page with buttons and Redux DevTools is in [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
|
|
6
|
+
|
|
7
|
+
## On this page
|
|
8
|
+
|
|
9
|
+
- [Start from the symptom](#start-from-the-symptom)
|
|
10
|
+
- [Find a loop](#find-a-loop)
|
|
11
|
+
- [Read a report](#read-a-report)
|
|
12
|
+
- [Draw the graph](#draw-the-graph)
|
|
13
|
+
- [See the loop in Redux DevTools](#see-the-loop-in-redux-devtools)
|
|
14
|
+
- [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows)
|
|
15
|
+
- [Break the loop](#break-the-loop)
|
|
16
|
+
- [Cut a loop at run time](#cut-a-loop-at-run-time)
|
|
17
|
+
- [Fail a test on a loop](#fail-a-test-on-a-loop)
|
|
18
|
+
- [Export the graph and the payloads](#export-the-graph-and-the-payloads)
|
|
19
|
+
- [Know the limits](#know-the-limits)
|
|
20
|
+
|
|
21
|
+
## Start from the symptom
|
|
22
|
+
|
|
23
|
+
| What you see | Likely cause | Go to |
|
|
24
|
+
| ------------ | ------------ | ----- |
|
|
25
|
+
| The page freezes | A loop of microtasks or a synchronous loop | [Find a loop](#find-a-loop), then [Cut a loop at run time](#cut-a-loop-at-run-time) |
|
|
26
|
+
| A request repeats on its own | A handler emits the event that starts it | [Read a report](#read-a-report) |
|
|
27
|
+
| `RangeError: Maximum call stack size exceeded` | A synchronous loop through `emit` | [Find a loop](#find-a-loop) |
|
|
28
|
+
| A component renders again and again | A value is sent to the bus on every render | [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows) |
|
|
29
|
+
| You do not know which event leads to which | No map of the events | [Draw the graph](#draw-the-graph) |
|
|
30
|
+
|
|
31
|
+
## Find a loop
|
|
32
|
+
|
|
33
|
+
`graphLogger` is a trace logger. Pass it to `createTracer` like any other logger. It builds the map of which event leads to which, and writes a report once when a chain passes the same event three times. Add `tracePlugin` so that it also sees every emit and the names of your listeners. It works without the plugin, with a coarser map.
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
// bus.js
|
|
37
|
+
import { createBus } from "nanoeffects";
|
|
38
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
39
|
+
|
|
40
|
+
// graphLogger — the map of events and the loop finder, in one logger
|
|
41
|
+
export const graph = graphLogger({ name: "cart" });
|
|
42
|
+
|
|
43
|
+
// createTracer — sends every task of the bus to the logger
|
|
44
|
+
const trace = createTracer(graph);
|
|
45
|
+
|
|
46
|
+
// tracePlugin — reports every emit, so the graph also shows events that nothing listens to
|
|
47
|
+
export const bus = createBus({ trace }).use(tracePlugin());
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The handlers are the part of your app that has the loop. Here a cart and a total update each other:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// cart-handlers.js
|
|
54
|
+
import { bus } from "./bus";
|
|
55
|
+
|
|
56
|
+
// traceId — the name of the listener in the graph and in the report
|
|
57
|
+
bus.on(
|
|
58
|
+
"cart/changed",
|
|
59
|
+
(cart, ctx) => {
|
|
60
|
+
ctx.emit("total/changed", cart);
|
|
61
|
+
},
|
|
62
|
+
{ traceId: "recalcTotal" },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
bus.on(
|
|
66
|
+
"total/changed",
|
|
67
|
+
(total, ctx) => {
|
|
68
|
+
ctx.emit("cart/changed", total);
|
|
69
|
+
},
|
|
70
|
+
{ traceId: "syncCart" },
|
|
71
|
+
);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`graphLogger` costs a lookup for each task. Use it while you develop, or with `createTracer(graph, { sample })` in production.
|
|
75
|
+
|
|
76
|
+
## Read a report
|
|
77
|
+
|
|
78
|
+
<!-- docs-check: continue -->
|
|
79
|
+
|
|
80
|
+
The report is one `console.warn` for each loop:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
[nanoeffects cart] cycle: recalcTotal → total/changed → syncCart → cart/changed → recalcTotal (repeated 3 times, started at cart/changed)
|
|
84
|
+
recalcTotal at src/cart-handlers.js:6; syncCart at src/cart-handlers.js:14 (only with devPlugin)
|
|
85
|
+
handlers feed each other: break the loop with a condition, or emit a different event for the result
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- **The path** goes from the first turn of the loop back to it. A named listener shows its event before it.
|
|
89
|
+
- **`started at`** is the first event of the chain.
|
|
90
|
+
- **`at file:line`** appears when you also use [`devPlugin`](hmr.md#report-leaks-with-devplugin).
|
|
91
|
+
- **The last line** is advice for this shape of loop.
|
|
92
|
+
|
|
93
|
+
To receive the finding in code, pass `onCycle`. It replaces the console report:
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
// report.js
|
|
97
|
+
import { graphLogger } from "nanoeffects/trace";
|
|
98
|
+
|
|
99
|
+
export const graph = graphLogger({
|
|
100
|
+
// onCycle — called once for each loop; the finding has a path, the repeats and the root
|
|
101
|
+
onCycle: (finding) => {
|
|
102
|
+
console.error(finding.kind, finding.path.join(" → "));
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`graph.cycles()` lists the loops found so far, with `turns`: how many times a loop went round after it was found. It also lists cycles that no single chain showed, when the edges of different runs close a loop (`source: "graph"`).
|
|
108
|
+
|
|
109
|
+
## Draw the graph
|
|
110
|
+
|
|
111
|
+
<!-- docs-check: continue -->
|
|
112
|
+
|
|
113
|
+
The graph has a count on each edge, and the edges of a cycle are marked. Write it in the format your tool reads:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
// draw.js
|
|
117
|
+
import { graph } from "./bus";
|
|
118
|
+
|
|
119
|
+
// Mermaid: paste it into a README, a pull request or https://mermaid.live
|
|
120
|
+
const mermaid = graph.toMermaid({ group: "prefix" }); // group the events by the part before "/"
|
|
121
|
+
|
|
122
|
+
// Graphviz: dot -Tsvg graph.dot > graph.svg
|
|
123
|
+
const dot = graph.toDot();
|
|
124
|
+
|
|
125
|
+
// plain data for your own tools
|
|
126
|
+
const data = graph.toJSON();
|
|
127
|
+
|
|
128
|
+
console.log(mermaid, dot, data);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```mermaid
|
|
132
|
+
flowchart LR
|
|
133
|
+
n0["cart/changed"]
|
|
134
|
+
n1["recalcTotal"]
|
|
135
|
+
n2["total/changed"]
|
|
136
|
+
n3["syncCart"]
|
|
137
|
+
n0 -->|x21| n1
|
|
138
|
+
n1 -->|x20| n2
|
|
139
|
+
n2 -->|x20| n3
|
|
140
|
+
n3 -->|x20| n0
|
|
141
|
+
linkStyle 0,1,2,3 stroke:red,stroke-width:2px
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The counts (`x21`) are how many times each edge was seen; they depend on how long the loop ran.
|
|
145
|
+
|
|
146
|
+
## See the loop in Redux DevTools
|
|
147
|
+
|
|
148
|
+
`devtools: true` sends each finding to the extension as a `cycle/detected` action. Put `reduxDevtools` next to it to also see the emits:
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
// devtools-bus.js
|
|
152
|
+
import { createBus } from "nanoeffects";
|
|
153
|
+
import { createTracer, graphLogger, reduxDevtools, tracePlugin } from "nanoeffects/trace";
|
|
154
|
+
|
|
155
|
+
// devtools: true — one cycle/detected action for each loop, with the path
|
|
156
|
+
const graph = graphLogger({ name: "app", devtools: true });
|
|
157
|
+
|
|
158
|
+
// reduxDevtools — the emits of the bus as actions
|
|
159
|
+
const trace = createTracer([reduxDevtools({ name: "app" }), graph]);
|
|
160
|
+
|
|
161
|
+
export const bus = createBus({ trace }).use(tracePlugin());
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Redux DevTools is a browser extension, so open the page in its own tab and pick the instance in its list. Without the extension nothing is sent and nothing throws. You can try it with the buttons of [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
|
|
165
|
+
|
|
166
|
+
## Find a loop that no chain shows
|
|
167
|
+
|
|
168
|
+
Some loops have no chain of emits:
|
|
169
|
+
|
|
170
|
+
- **A component that emits on every render.** `useEmitter` and a `bus.emit` in an effect run outside any handler, so each emit is a root. `graphLogger` counts such emits for each event, and reports a **storm** when one event comes 100 times in a second. Change the limit with `storm: { count, perMs }`, or turn it off with `storm: false`.
|
|
171
|
+
- **A `bus.emit` inside a handler.** It starts a new chain, so the handler that calls it again does not look like a loop. Use `ctx.emit` inside handlers to keep the chain.
|
|
172
|
+
- **A chain of generated names** (`step/${n}`) never repeats a name. `graphLogger` reports a **runaway** chain when one root starts more than `maxSteps` tasks (5000 by default).
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
// storm.js
|
|
176
|
+
import { graphLogger } from "nanoeffects/trace";
|
|
177
|
+
|
|
178
|
+
export const graph = graphLogger({
|
|
179
|
+
storm: { count: 50, perMs: 1000 }, // 50 emits of one event in a second from outside
|
|
180
|
+
maxSteps: 2000, // 2000 tasks started by one root
|
|
181
|
+
ignore: ["tree/visit"], // a recursion on purpose is not counted
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Break the loop
|
|
186
|
+
|
|
187
|
+
| Shape | Fix |
|
|
188
|
+
| ----- | --- |
|
|
189
|
+
| A handler emits its own event | Emit a different event for the result (`user/loaded`, not `user/load`). |
|
|
190
|
+
| Two handlers feed each other | Add the condition that ends it, or send the result to a third event that nothing in the loop listens to. |
|
|
191
|
+
| A job whose outcome has the type of its trigger | Give the outcomes their own types. |
|
|
192
|
+
| A component emits on every render | Pass `equals` to `useEmitter`, so the same value is not an emit, or `debounce` the handler. |
|
|
193
|
+
| A failure re-emits the event that failed | Stop after a number of tries with `retry`, not with an event. |
|
|
194
|
+
|
|
195
|
+
## Cut a loop at run time
|
|
196
|
+
|
|
197
|
+
The report does not stop a loop. For a loop that must not take the page down, add [`loopGuardPlugin`](loop-guard.md). It refuses the emit that would pass 50 repeats, or a chain of 1000 emits, and reports it:
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
// guarded-bus.js
|
|
201
|
+
import { createBus } from "nanoeffects";
|
|
202
|
+
import { loopGuardPlugin } from "nanoeffects/guard";
|
|
203
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
204
|
+
|
|
205
|
+
const graph = graphLogger({ name: "app" });
|
|
206
|
+
const trace = createTracer(graph);
|
|
207
|
+
|
|
208
|
+
// loopGuardPlugin — cuts the loop that the graph reports
|
|
209
|
+
export const bus = createBus({ trace })
|
|
210
|
+
.use(tracePlugin())
|
|
211
|
+
.use(loopGuardPlugin({ maxRepeats: 50 }));
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The guard is independent of the logger: use either one alone.
|
|
215
|
+
|
|
216
|
+
## Fail a test on a loop
|
|
217
|
+
|
|
218
|
+
Run a flow of the app and check that the graph has no cycle. A loop that someone adds later fails the build.
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
// bus.js
|
|
222
|
+
import { createBus } from "nanoeffects";
|
|
223
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
224
|
+
|
|
225
|
+
// the bus.js of "Find a loop": the graph logger on the tracer of the bus
|
|
226
|
+
export const graph = graphLogger({ name: "cart" });
|
|
227
|
+
export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
// cart-handlers.js
|
|
232
|
+
import { bus } from "./bus";
|
|
233
|
+
|
|
234
|
+
bus.on("checkout/started", (_order, ctx) => {
|
|
235
|
+
ctx.emit("checkout/paid");
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
// cycles.test.js
|
|
241
|
+
import { expect, test } from "vitest";
|
|
242
|
+
import { bus, graph } from "./bus";
|
|
243
|
+
import "./cart-handlers";
|
|
244
|
+
|
|
245
|
+
test("the checkout flow has no event cycles", () => {
|
|
246
|
+
// run the flow the way a user would
|
|
247
|
+
bus.emit("checkout/started");
|
|
248
|
+
|
|
249
|
+
// cycles() — empty when the graph has no loop
|
|
250
|
+
expect(graph.cycles()).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Export the graph and the payloads
|
|
255
|
+
|
|
256
|
+
The exports leave payloads out, because a payload can hold private data. Ask for them when you need them:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
// bus.js
|
|
260
|
+
import { createBus } from "nanoeffects";
|
|
261
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
262
|
+
|
|
263
|
+
// the bus.js of "Find a loop": the graph logger on the tracer of the bus
|
|
264
|
+
export const graph = graphLogger({ name: "cart" });
|
|
265
|
+
export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
// payloads.js
|
|
270
|
+
import { graph } from "./bus";
|
|
271
|
+
|
|
272
|
+
// payload: true — adds the last payload and error of each node, made safe
|
|
273
|
+
const data = graph.toJSON({ payload: true });
|
|
274
|
+
|
|
275
|
+
console.log(JSON.stringify(data));
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A payload can be a class instance, a `Map`, a circular object or an `Error`, which a plain `JSON.stringify` cannot take. The exports make them plain JSON: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ "$map": [...] }`, and an instance of a class keeps its own fields and its name as `$class`. Getters are never called, and a value that is too deep or too big is cut.
|
|
279
|
+
|
|
280
|
+
## Know the limits
|
|
281
|
+
|
|
282
|
+
- A chain that repeats on purpose is reported. List its event in `ignore`.
|
|
283
|
+
- The report is made once for each loop. `turns` in `graph.cycles()` keeps counting.
|