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