nanoeffects 0.28.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-6rlBTmH9.d.ts} +60 -33
- package/dist/actions.d.ts +18 -18
- package/dist/actions.js +1 -1
- package/dist/bus-DFlmNJ6h.js +1 -0
- package/dist/context-B5Z2RpJt.js +1 -0
- package/dist/context-types-DKNc5mkS.d.ts +31 -0
- package/dist/context.d.ts +8 -3
- package/dist/context.js +1 -1
- package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
- package/dist/dev.d.ts +44 -3
- package/dist/dev.js +2 -1
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/emit-key-C6FL7Ewn.js +1 -0
- package/dist/guard.d.ts +59 -0
- package/dist/guard.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/match.d.ts +3 -3
- package/dist/match.js +1 -1
- package/dist/{module-C_oEXw1n.d.ts → module-G39MaVOt.d.ts} +38 -38
- package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-BrMSO3RP.d.ts} +35 -32
- package/dist/notrace/actions.d.ts +52 -52
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-lJjjd-SN.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 +43 -1
- package/dist/observable.js +1 -1
- package/dist/onAbort-BgMfspK4.js +1 -0
- package/dist/oop.d.ts +3 -3
- package/dist/oop.js +1 -1
- package/dist/{plugin-CuvIgjnX.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 +9 -9
- package/dist/signal-field-key-CWT-3OXC.js +1 -0
- package/dist/signal.d.ts +34 -6
- 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 +170 -6
- package/dist/trace.js +3 -1
- package/dist/{types-FV8fuvI9.d.ts → types-N_cdRr1r.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-D-SQRkPh.js → with-policy-CeM1rtES.js} +1 -1
- package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
- package/docs/README.md +65 -22
- package/docs/actions.md +56 -13
- package/docs/cancel-stale-requests.md +2 -0
- package/docs/chat-example.md +15 -1
- package/docs/chat-widget.md +9 -7
- package/docs/context.md +352 -8
- package/docs/debounce-and-abort.md +3 -1
- package/docs/debugging-loops.md +283 -0
- package/docs/effects.md +259 -131
- package/docs/errors.md +4 -2
- package/docs/examples.md +60 -0
- package/docs/guide.md +57 -22
- package/docs/hmr.md +35 -1
- package/docs/loop-guard.md +109 -0
- package/docs/migrating-from-effector.md +15 -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 +45 -0
- package/docs/oop.md +20 -1
- package/docs/package.md +25 -13
- package/docs/pass-the-signal.md +281 -0
- package/docs/plugins.md +90 -15
- package/docs/policies.md +73 -46
- package/docs/prevent-double-submit.md +3 -1
- package/docs/query-libraries.md +276 -6
- package/docs/react.md +50 -11
- package/docs/recipes.md +528 -0
- package/docs/ru/README.md +62 -0
- package/docs/ru/actions.md +412 -0
- package/docs/ru/cancel-stale-requests.md +106 -0
- package/docs/ru/chat-example.md +259 -0
- package/docs/ru/chat-widget.md +131 -0
- package/docs/ru/context.md +401 -0
- package/docs/ru/debounce-and-abort.md +105 -0
- package/docs/ru/debugging-loops.md +283 -0
- package/docs/ru/effects.md +538 -0
- package/docs/ru/errors.md +36 -0
- package/docs/ru/examples.md +60 -0
- package/docs/ru/guide.md +530 -0
- package/docs/ru/hmr.md +83 -0
- package/docs/ru/loop-guard.md +109 -0
- package/docs/ru/migrating-from-effector.md +339 -0
- package/docs/ru/migrating-from-mitt.md +99 -0
- package/docs/ru/migrating-from-rxjs.md +197 -0
- package/docs/ru/migrating-from-saga.md +97 -0
- package/docs/ru/motivation.md +47 -0
- package/docs/ru/observable.md +233 -0
- package/docs/ru/oop.md +431 -0
- package/docs/ru/package.md +42 -0
- package/docs/ru/pass-the-signal.md +281 -0
- package/docs/ru/plugins.md +398 -0
- package/docs/ru/policies.md +201 -0
- package/docs/ru/prevent-double-submit.md +122 -0
- package/docs/ru/query-libraries.md +392 -0
- package/docs/ru/react.md +309 -0
- package/docs/ru/recipes.md +528 -0
- package/docs/ru/semantics.md +19 -0
- package/docs/ru/server.md +204 -0
- package/docs/ru/starter.md +118 -0
- package/docs/ru/stores.md +673 -0
- package/docs/ru/svelte.md +78 -0
- package/docs/ru/tracing.md +466 -0
- package/docs/ru/typescript.md +170 -0
- package/docs/ru/vue.md +92 -0
- package/docs/semantics.md +12 -0
- package/docs/server.md +5 -3
- package/docs/starter.md +2 -0
- package/docs/stores.md +2 -0
- package/docs/svelte.md +3 -1
- package/docs/tracing.md +132 -7
- package/docs/typescript.md +3 -1
- package/docs/vue.md +3 -1
- package/llms-full.txt +2718 -545
- package/llms.txt +17 -14
- package/package.json +25 -8
- package/skills/nanoeffects/SKILL.md +2 -2
- package/dist/bus-DlpEluzd.js +0 -1
- package/dist/context-UcmrAIhP.js +0 -1
- package/dist/context-types-hCKgU8sC.d.ts +0 -14
- package/docs/scope.md +0 -266
- package/docs/stability.md +0 -90
package/llms-full.txt
CHANGED
|
@@ -9,25 +9,44 @@ 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`.
|
|
13
|
-
- `bus.emit(
|
|
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
|
+
- `bus.emit(action(payload))` runs the action's handler and returns its result. Plain events return nothing.
|
|
14
14
|
|
|
15
15
|
---
|
|
16
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
|
|
|
33
|
+
## On this page
|
|
34
|
+
|
|
35
|
+
- [1. Create a bus](#1-create-a-bus)
|
|
36
|
+
- [Listen to every event](#listen-to-every-event)
|
|
37
|
+
- [Pick events with a function](#pick-events-with-a-function)
|
|
38
|
+
- [2. Use the handler context](#2-use-the-handler-context)
|
|
39
|
+
- [3. Abort the previous run with `latest`](#3-abort-the-previous-run-with-latest)
|
|
40
|
+
- [4. Pick another policy when overlap differs](#4-pick-another-policy-when-overlap-differs)
|
|
41
|
+
- [5. Add retry and timeout](#5-add-retry-and-timeout)
|
|
42
|
+
- [6. Run work in parallel or wait for an event](#6-run-work-in-parallel-or-wait-for-an-event)
|
|
43
|
+
- [7. Name events three ways](#7-name-events-three-ways)
|
|
44
|
+
- [8. Replace a handler on the fly](#8-replace-a-handler-on-the-fly)
|
|
45
|
+
- [9. Mount a domain as a module](#9-mount-a-domain-as-a-module)
|
|
46
|
+
- [10. Add types when you want checking (optional)](#10-add-types-when-you-want-checking-optional)
|
|
47
|
+
- [11. Isolate errors and warn on typos](#11-isolate-errors-and-warn-on-typos)
|
|
48
|
+
- [Where next](#where-next)
|
|
49
|
+
|
|
31
50
|
## 1. Create a bus
|
|
32
51
|
|
|
33
52
|
`on` subscribes. `emit` calls the subscribers. `on` returns the unsubscribe function.
|
|
@@ -48,7 +67,12 @@ bus.emit("greet", "Bob"); // nobody listens
|
|
|
48
67
|
|
|
49
68
|
Handlers run at once, in subscription order.
|
|
50
69
|
|
|
51
|
-
|
|
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`.
|
|
52
76
|
|
|
53
77
|
```js
|
|
54
78
|
import { createBus } from "nanoeffects";
|
|
@@ -66,6 +90,8 @@ bus.off(); // clear every subscription
|
|
|
66
90
|
|
|
67
91
|
### Listen to every event
|
|
68
92
|
|
|
93
|
+
You can skip this and the next section on a first read.
|
|
94
|
+
|
|
69
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.
|
|
70
96
|
|
|
71
97
|
```js
|
|
@@ -88,7 +114,7 @@ unbind();
|
|
|
88
114
|
|
|
89
115
|
### Pick events with a function
|
|
90
116
|
|
|
91
|
-
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.
|
|
92
118
|
|
|
93
119
|
<!-- docs-check: new -->
|
|
94
120
|
|
|
@@ -234,7 +260,7 @@ const searchLatest = latest(async (query, { signal }) => {
|
|
|
234
260
|
bus.on("search", debounce(300, searchLatest));
|
|
235
261
|
```
|
|
236
262
|
|
|
237
|
-
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).
|
|
238
264
|
|
|
239
265
|
## 5. Add retry and timeout
|
|
240
266
|
|
|
@@ -289,10 +315,11 @@ import { all, delay, race, take } from "nanoeffects/effects";
|
|
|
289
315
|
const bus = createBus();
|
|
290
316
|
|
|
291
317
|
// all — run together; one failure cancels the rest
|
|
318
|
+
// child — the context of that request: it has the `signal`, so the request stops with the group
|
|
292
319
|
bus.on("search", async (id, ctx) => {
|
|
293
320
|
const { user, posts } = await all(ctx, {
|
|
294
|
-
user: (
|
|
295
|
-
posts: (
|
|
321
|
+
user: (child) => api.user(id, child),
|
|
322
|
+
posts: (child) => api.posts(id, child),
|
|
296
323
|
});
|
|
297
324
|
ctx.emit("loaded", { user, posts });
|
|
298
325
|
});
|
|
@@ -363,9 +390,11 @@ bus.emit(double(21)); // 42
|
|
|
363
390
|
|
|
364
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`.
|
|
365
392
|
|
|
366
|
-
## 8.
|
|
393
|
+
## 8. Replace a handler on the fly
|
|
367
394
|
|
|
368
|
-
|
|
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.
|
|
369
398
|
|
|
370
399
|
```js
|
|
371
400
|
// user.js
|
|
@@ -377,26 +406,32 @@ export const getUser = createAction("user/get"); // no handler yet: an event
|
|
|
377
406
|
```js
|
|
378
407
|
// bus.js
|
|
379
408
|
import { createBus } from "nanoeffects";
|
|
380
|
-
import { actionsPlugin } from "nanoeffects/actions";
|
|
409
|
+
import { actionsPlugin, setHandler } from "nanoeffects/actions";
|
|
410
|
+
import { getUser } from "./user";
|
|
381
411
|
|
|
382
412
|
export const bus = createBus();
|
|
383
|
-
|
|
384
|
-
|
|
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]));
|
|
385
419
|
```
|
|
386
420
|
|
|
387
421
|
```js
|
|
388
422
|
import { bus, actions } from "./bus";
|
|
389
423
|
import { getUser } from "./user";
|
|
390
424
|
|
|
391
|
-
bus.emit(getUser("1")); //
|
|
425
|
+
const user = await bus.emit(getUser("1")); // the real handler answers
|
|
392
426
|
|
|
393
|
-
|
|
394
|
-
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
|
|
395
430
|
|
|
396
|
-
|
|
431
|
+
restore(); // the real handler answers again
|
|
397
432
|
```
|
|
398
433
|
|
|
399
|
-
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).
|
|
400
435
|
|
|
401
436
|
## 9. Mount a domain as a module
|
|
402
437
|
|
|
@@ -513,336 +548,825 @@ More in [Errors](errors.md).
|
|
|
513
548
|
| Which import | [Package entries](package.md) |
|
|
514
549
|
|
|
515
550
|
---
|
|
516
|
-
Source: docs/
|
|
517
|
-
|
|
518
|
-
# Start a project from a template
|
|
551
|
+
Source: docs/recipes.md
|
|
519
552
|
|
|
520
|
-
|
|
553
|
+
# Common tasks
|
|
521
554
|
|
|
522
|
-
|
|
555
|
+
> **Levels A to E.** Read first: [README](../README.md).
|
|
523
556
|
|
|
524
|
-
|
|
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.
|
|
525
558
|
|
|
526
|
-
|
|
559
|
+
## On this page
|
|
527
560
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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`.
|
|
531
595
|
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
```
|
|
596
|
+
```js
|
|
597
|
+
import { createBus } from "nanoeffects";
|
|
535
598
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
```
|
|
599
|
+
// createBus({ context }) — every task gets these fields on ctx
|
|
600
|
+
const bus = createBus({ context: { api, logger } });
|
|
539
601
|
|
|
540
|
-
|
|
541
|
-
|
|
602
|
+
bus.on("save", async (draft, { api, logger }) => {
|
|
603
|
+
await api.save(draft);
|
|
604
|
+
logger.info("saved");
|
|
605
|
+
});
|
|
542
606
|
```
|
|
543
607
|
|
|
544
|
-
|
|
608
|
+
More: [Context fields](context.md).
|
|
545
609
|
|
|
546
|
-
|
|
610
|
+
### One handler fails, the others must still run
|
|
547
611
|
|
|
548
|
-
|
|
549
|
-
cd my-app
|
|
550
|
-
npm install
|
|
551
|
-
npm run dev
|
|
552
|
-
```
|
|
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.
|
|
553
613
|
|
|
554
|
-
|
|
614
|
+
```js
|
|
615
|
+
import { createBus } from "nanoeffects";
|
|
555
616
|
|
|
556
|
-
|
|
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
|
+
});
|
|
557
621
|
|
|
558
|
-
|
|
622
|
+
const sendAnalytics = (order) => {
|
|
623
|
+
throw new Error(`analytics is down for ${order.id}`);
|
|
624
|
+
};
|
|
625
|
+
const saveOrder = (order) => console.log("saved", order.id);
|
|
559
626
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
| `vanilla` | A bus, one event and a `debounce` plus `latest` handler, with plain DOM code | You use no framework, or want to see the bus alone |
|
|
563
|
-
| `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
|
|
564
|
-
| `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
|
|
565
|
-
| `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
|
|
566
|
-
| `domains-ts` | Actions with handlers, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
|
|
567
|
-
| `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
|
|
568
|
-
| `svelte-ts` | The `react-ts` app in Svelte 5, with `eventStore` from `nanoeffects/svelte` | You build a Svelte app |
|
|
627
|
+
bus.on("order/placed", sendAnalytics); // throws
|
|
628
|
+
bus.on("order/placed", saveOrder); // still runs
|
|
569
629
|
|
|
570
|
-
|
|
571
|
-
npm create nanoeffects@latest my-app -- --template react
|
|
630
|
+
bus.emit("order/placed", { id: 1 }); // saved 1
|
|
572
631
|
```
|
|
573
632
|
|
|
574
|
-
|
|
633
|
+
More: [Errors and unheard events](errors.md#isolate-failing-handlers).
|
|
575
634
|
|
|
576
|
-
|
|
635
|
+
### A typo in an event name
|
|
577
636
|
|
|
578
|
-
|
|
579
|
-
npm create nanoeffects@latest my-app -- --template react-ts
|
|
580
|
-
```
|
|
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.
|
|
581
638
|
|
|
582
|
-
|
|
639
|
+
```js
|
|
640
|
+
import { createBus } from "nanoeffects";
|
|
583
641
|
|
|
584
|
-
|
|
642
|
+
const bus = createBus({
|
|
643
|
+
// onUnhandled — called when an event has no listeners
|
|
644
|
+
onUnhandled: ({ type }) => console.warn(`Nobody listens to "${type}"`),
|
|
645
|
+
});
|
|
585
646
|
|
|
586
|
-
|
|
587
|
-
|
|
647
|
+
bus.on("search", (query) => console.log(query));
|
|
648
|
+
|
|
649
|
+
bus.emit("serch", "nano"); // warning: Nobody listens to "serch"
|
|
588
650
|
```
|
|
589
651
|
|
|
590
|
-
|
|
652
|
+
More: [Warn when nobody listens](errors.md#warn-when-nobody-listens).
|
|
591
653
|
|
|
592
|
-
|
|
654
|
+
## Races and repeated actions in the UI
|
|
593
655
|
|
|
594
|
-
|
|
656
|
+
### An old response overwrites a new one
|
|
595
657
|
|
|
596
|
-
|
|
597
|
-
npm create nanoeffects@latest my-app -- --template svelte-ts
|
|
598
|
-
```
|
|
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:
|
|
599
659
|
|
|
600
|
-
|
|
660
|
+
```jsx
|
|
661
|
+
import { useEffect, useState } from "react";
|
|
601
662
|
|
|
602
|
-
|
|
663
|
+
export function Search({ query }) {
|
|
664
|
+
const [items, setItems] = useState([]);
|
|
603
665
|
|
|
604
|
-
|
|
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]);
|
|
605
670
|
|
|
606
|
-
|
|
607
|
-
|
|
671
|
+
return (
|
|
672
|
+
<ul>
|
|
673
|
+
{items.map((item) => (
|
|
674
|
+
<li key={item.id}>{item.title}</li>
|
|
675
|
+
))}
|
|
676
|
+
</ul>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
608
679
|
```
|
|
609
680
|
|
|
610
|
-
|
|
611
|
-
| ---- | --- |
|
|
612
|
-
| `src/bus.ts` | Creates the bus. It imports no events and no domain. |
|
|
613
|
-
| `src/app.ts` | Mounts the domains with `modulePlugin`. |
|
|
614
|
-
| `src/domains/catalog/` | Searching products: `catalog.ts` (events and an action with a handler), `catalog-handlers.ts` (`debounce` and `latest`), `catalog-module.ts`. |
|
|
615
|
-
| `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
|
|
616
|
-
| `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
|
|
617
|
-
|
|
618
|
-
For a bigger example, a chat in which parts react to each other, see [Build a chat with entities, features, modules and a small server](chat-example.md). It has more layers than `domains-ts`: when domains must react to each other, it keeps them from importing each other by splitting them into entities and features.
|
|
681
|
+
Move the request into a handler with `latest`: a new `search` aborts the previous request, and the aborted one delivers no result.
|
|
619
682
|
|
|
620
|
-
|
|
683
|
+
```js
|
|
684
|
+
import { createBus } from "nanoeffects";
|
|
685
|
+
import { latest } from "nanoeffects/policies";
|
|
621
686
|
|
|
622
|
-
|
|
687
|
+
const bus = createBus();
|
|
623
688
|
|
|
624
|
-
|
|
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
|
+
);
|
|
625
698
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
| `src/bus.js` | Creates the bus. It imports no events. |
|
|
629
|
-
| `src/search.js` | Names the events with `createAction`. It imports no bus. |
|
|
630
|
-
| `src/search-handlers.js` | Handles the events with `debounce` and `latest`. It knows the bus and the events. |
|
|
631
|
-
| `src/api.js` | A fake server. Replace it with your own requests. |
|
|
699
|
+
bus.on("results", (items) => render(items));
|
|
700
|
+
```
|
|
632
701
|
|
|
633
|
-
|
|
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).
|
|
634
703
|
|
|
635
|
-
|
|
704
|
+
### A search box sends a request on every key
|
|
636
705
|
|
|
637
|
-
|
|
638
|
-
- [Build a sign-up form in React](react.md): the same ideas in a bigger React example.
|
|
639
|
-
- [Package entries](package.md): which import holds what.
|
|
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.
|
|
640
707
|
|
|
641
|
-
|
|
642
|
-
|
|
708
|
+
```js
|
|
709
|
+
import { createBus } from "nanoeffects";
|
|
710
|
+
import { debounce, latest } from "nanoeffects/policies";
|
|
643
711
|
|
|
644
|
-
|
|
712
|
+
const bus = createBus();
|
|
645
713
|
|
|
646
|
-
|
|
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
|
+
});
|
|
647
719
|
|
|
648
|
-
|
|
720
|
+
// debounce — start after 300 ms without a new "typed"
|
|
721
|
+
bus.on("typed", debounce(300, searchLatest));
|
|
722
|
+
```
|
|
649
723
|
|
|
650
|
-
|
|
724
|
+
More: [Pause between requests](debounce-and-abort.md).
|
|
651
725
|
|
|
652
|
-
|
|
726
|
+
### A second click sends a second request
|
|
653
727
|
|
|
654
|
-
|
|
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.
|
|
655
729
|
|
|
656
|
-
|
|
730
|
+
```js
|
|
731
|
+
import { createBus } from "nanoeffects";
|
|
732
|
+
import { leading } from "nanoeffects/policies";
|
|
657
733
|
|
|
658
|
-
|
|
734
|
+
const bus = createBus();
|
|
659
735
|
|
|
660
|
-
|
|
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
|
+
```
|
|
661
744
|
|
|
662
|
-
|
|
663
|
-
| ------- | --------------- | ------ |
|
|
664
|
-
| Redux, React Redux | A shared component library uses `react-redux`. Every micro-frontend must use the same version of that library, or `react-redux` "breaks weirdly". | [react-redux#1949](https://github.com/reduxjs/react-redux/issues/1949) |
|
|
665
|
-
| Redux | A store is reachable only under a `Provider`. Each sub-app needs its own provider, and a second one must not be created when a module is injected. Reducers duplicated between the host and a module add to it. | [Redux on Module Federation](https://dev.to/manomano-tech-team/how-to-work-with-redux-on-module-federation-13gm) |
|
|
666
|
-
| 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) |
|
|
667
|
-
| 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) |
|
|
745
|
+
More: [Prevent a double submit](prevent-double-submit.md).
|
|
668
746
|
|
|
669
|
-
|
|
747
|
+
### Messages must go out in order
|
|
670
748
|
|
|
671
|
-
|
|
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.
|
|
672
750
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
| Effector | A graph with global identity. A version or scope mismatch between the SDK and the app reaches the whole infrastructure. |
|
|
677
|
-
| 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). |
|
|
678
|
-
| 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. |
|
|
751
|
+
```js
|
|
752
|
+
import { createBus } from "nanoeffects";
|
|
753
|
+
import { queue } from "nanoeffects/policies";
|
|
679
754
|
|
|
680
|
-
|
|
755
|
+
const bus = createBus();
|
|
681
756
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
+
```
|
|
687
765
|
|
|
688
|
-
|
|
766
|
+
More: [Policies, `queue`](policies.md#run-calls-one-after-another-with-queue).
|
|
689
767
|
|
|
690
|
-
|
|
768
|
+
### A "Delete" button in a list blocks the other rows
|
|
691
769
|
|
|
692
|
-
|
|
693
|
-
Source: docs/package.md
|
|
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.
|
|
694
771
|
|
|
695
|
-
|
|
772
|
+
```js
|
|
773
|
+
import { createBus } from "nanoeffects";
|
|
774
|
+
import { leading } from "nanoeffects/policies";
|
|
696
775
|
|
|
697
|
-
|
|
776
|
+
const bus = createBus();
|
|
698
777
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
| `nanoeffects/redux` | `createEffectsMiddleware` |
|
|
711
|
-
| `nanoeffects/observable` | `createObserve`, `Subject` |
|
|
712
|
-
| `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
|
|
713
|
-
| `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) |
|
|
714
|
-
| `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
|
|
715
|
-
| `nanoeffects/dev` | `devPlugin` (no-op in production) |
|
|
716
|
-
| `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
|
|
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
|
+
```
|
|
717
789
|
|
|
718
|
-
|
|
790
|
+
More: [Policies, `key`](policies.md#key-isolate-state-by-key).
|
|
719
791
|
|
|
720
|
-
##
|
|
792
|
+
## An unreliable network and long flows
|
|
721
793
|
|
|
722
|
-
|
|
794
|
+
### A request fails sometimes: retry and backoff
|
|
723
795
|
|
|
724
|
-
|
|
725
|
-
- `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
|
|
726
|
-
- `skills/nanoeffects/SKILL.md`: a skill for assistants that support skills (for example Claude Code). It says when to suggest nanoeffects, when not to, and the same rules. Copy the folder to `.claude/skills/` in your project or to `~/.claude/skills/`.
|
|
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.
|
|
727
797
|
|
|
728
|
-
|
|
798
|
+
```js
|
|
799
|
+
import { createBus } from "nanoeffects";
|
|
800
|
+
import { backoff, retry } from "nanoeffects/effects";
|
|
729
801
|
|
|
730
|
-
|
|
731
|
-
Source: docs/stability.md
|
|
802
|
+
const bus = createBus();
|
|
732
803
|
|
|
733
|
-
|
|
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
|
+
});
|
|
734
814
|
|
|
735
|
-
|
|
815
|
+
bus.on("user/opened", loadWithRetry);
|
|
816
|
+
```
|
|
736
817
|
|
|
737
|
-
|
|
818
|
+
More: [`backoff`](effects.md#backoff).
|
|
738
819
|
|
|
739
|
-
A
|
|
820
|
+
### A request hangs: timeout
|
|
740
821
|
|
|
741
|
-
|
|
742
|
-
| ------ | ------ | ---- |
|
|
743
|
-
| `nanoeffects` | Stable | `createBus` and the types of a bus |
|
|
744
|
-
| `nanoeffects/effects` | Stable | `retry`, `timeout`, `poll`, `take`, `delay`, `all`, `allSettled`, `race`, `backoff`, `cancellable`, `effectsPlugin` |
|
|
745
|
-
| `nanoeffects/policies` | Stable | `latest`, `leading`, `debounce`, `throttle`, `queue`, `withPolicy` |
|
|
746
|
-
| `nanoeffects/actions` | Stable | `createAction`, `createJob`, `createModule` and their plugins |
|
|
747
|
-
| `nanoeffects/trace` | Stable: the contract | `Tracer`, `Trace`, `TraceEvent`, `TraceLogger`, `Phase`, `EffectName` and `tracePlugin` |
|
|
748
|
-
| `nanoeffects/trace` | Unstable: the tools | `createTracer`, `consoleLogger`, `performanceLogger`, `createTraceRecorder`, `reduxDevtools` and their option types |
|
|
749
|
-
| `nanoeffects/scope` | Unstable | `scopePlugin`, `effect` |
|
|
750
|
-
| `nanoeffects/match` | Unstable | `matchPlugin` |
|
|
751
|
-
| `nanoeffects/signal` | Unstable | `signalPlugin` |
|
|
752
|
-
| `nanoeffects/context` | Unstable | `contextPlugin` |
|
|
753
|
-
| `nanoeffects/oop` | Unstable | `Emitter`, `extendBus`, `On`, `Once`, `WithBus`, `disposeBus` |
|
|
754
|
-
| `nanoeffects/observable` | Unstable | `createObserve`, `Subject` |
|
|
755
|
-
| `nanoeffects/redux` | Unstable | `createEffectsMiddleware` |
|
|
756
|
-
| `nanoeffects/react` | Unstable | `reactPlugin`, `usePolicy` |
|
|
757
|
-
| `nanoeffects/svelte` | Unstable | `sveltePlugin` |
|
|
758
|
-
| `nanoeffects/vue` | Unstable | `vuePlugin` |
|
|
759
|
-
| `nanoeffects/dev` | Unstable | `devPlugin` |
|
|
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.
|
|
760
823
|
|
|
761
|
-
|
|
824
|
+
```js
|
|
825
|
+
import { createBus } from "nanoeffects";
|
|
826
|
+
import { timeout } from "nanoeffects/effects";
|
|
762
827
|
|
|
763
|
-
|
|
828
|
+
const bus = createBus();
|
|
764
829
|
|
|
765
|
-
|
|
830
|
+
// timeout — reject with TimeoutError and abort the request after 5 s
|
|
831
|
+
const loadOnce = timeout(5000, (id, { signal }) => api.getUser(id, { signal }));
|
|
766
832
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
- A removed or renamed export.
|
|
833
|
+
bus.on("user/opened", loadOnce);
|
|
834
|
+
```
|
|
770
835
|
|
|
771
|
-
|
|
836
|
+
More: [`timeout`](effects.md#timeout).
|
|
772
837
|
|
|
773
|
-
|
|
838
|
+
### Refresh data every N seconds: poll
|
|
774
839
|
|
|
775
|
-
|
|
776
|
-
- The text of the messages of `nanoeffects/dev`.
|
|
777
|
-
- 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.
|
|
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.
|
|
778
841
|
|
|
779
|
-
|
|
842
|
+
```js
|
|
843
|
+
import { createBus } from "nanoeffects";
|
|
844
|
+
import { poll } from "nanoeffects/effects";
|
|
780
845
|
|
|
781
|
-
|
|
846
|
+
const bus = createBus();
|
|
782
847
|
|
|
783
|
-
|
|
848
|
+
bus.on("dashboard/opened", async (_, ctx) => {
|
|
849
|
+
// stop the polling when the user leaves
|
|
850
|
+
ctx.on("dashboard/closed", () => ctx.abort());
|
|
784
851
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
| 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. |
|
|
790
|
-
| React | 18 or newer (`nanoeffects/react`) |
|
|
791
|
-
| Vue | 3.3 or newer (`nanoeffects/vue`) |
|
|
792
|
-
| Svelte | 5 or newer (`nanoeffects/svelte`) |
|
|
852
|
+
// poll — call refresh every 10 s until the task is aborted
|
|
853
|
+
await poll(ctx, () => refresh(), 10_000);
|
|
854
|
+
});
|
|
855
|
+
```
|
|
793
856
|
|
|
794
|
-
|
|
857
|
+
More: [`poll`](effects.md#poll).
|
|
795
858
|
|
|
796
|
-
|
|
859
|
+
### Wait for another event inside a handler: take
|
|
797
860
|
|
|
798
|
-
|
|
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.
|
|
799
862
|
|
|
800
863
|
```js
|
|
801
864
|
import { createBus } from "nanoeffects";
|
|
802
|
-
import {
|
|
865
|
+
import { take } from "nanoeffects/effects";
|
|
803
866
|
|
|
804
867
|
const bus = createBus();
|
|
805
868
|
|
|
806
|
-
bus.on("
|
|
807
|
-
//
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
// as a starter: a group (race, all, allSettled, timeout) calls it with its child
|
|
811
|
-
const result = await race(ctx, {
|
|
812
|
-
user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
|
|
813
|
-
});
|
|
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);
|
|
814
873
|
});
|
|
815
|
-
|
|
816
|
-
// as a Task: a function of (payload, ctx), which bus.on and ctx.run call
|
|
817
|
-
bus.on("reload", retry((id, { signal }) => fetchUser(id, { signal }), { times: 3 }));
|
|
818
874
|
```
|
|
819
875
|
|
|
820
|
-
|
|
876
|
+
More: [`take`](effects.md#take).
|
|
821
877
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
---
|
|
825
|
-
Source: docs/plugins.md
|
|
878
|
+
### Load several things at once: all
|
|
826
879
|
|
|
827
|
-
|
|
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.
|
|
828
881
|
|
|
829
|
-
|
|
882
|
+
```js
|
|
883
|
+
import { createBus } from "nanoeffects";
|
|
884
|
+
import { all } from "nanoeffects/effects";
|
|
830
885
|
|
|
831
|
-
|
|
886
|
+
const bus = createBus();
|
|
832
887
|
|
|
833
|
-
|
|
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
|
+
});
|
|
834
894
|
|
|
835
|
-
|
|
895
|
+
ctx.emit("profile/loaded", { user, posts });
|
|
896
|
+
});
|
|
897
|
+
```
|
|
836
898
|
|
|
837
|
-
|
|
899
|
+
### Take the fastest answer: race
|
|
838
900
|
|
|
839
|
-
|
|
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
|
+
|
|
1082
|
+
---
|
|
1083
|
+
Source: docs/starter.md
|
|
1084
|
+
|
|
1085
|
+
# Start a project from a template
|
|
1086
|
+
|
|
1087
|
+
> **Level A.** Read first: nothing: this page makes a project for you.
|
|
1088
|
+
|
|
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`).
|
|
1090
|
+
|
|
1091
|
+
You need Node 18 or newer.
|
|
1092
|
+
|
|
1093
|
+
## Create the project
|
|
1094
|
+
|
|
1095
|
+
Run the command for your package manager. Replace `my-app` with the name of the new directory.
|
|
1096
|
+
|
|
1097
|
+
```bash
|
|
1098
|
+
npm create nanoeffects@latest my-app
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
```bash
|
|
1102
|
+
pnpm create nanoeffects my-app
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
```bash
|
|
1106
|
+
yarn create nanoeffects my-app
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
```bash
|
|
1110
|
+
bun create nanoeffects my-app
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
The command asks for the template when you do not give one. The directory must be new or empty: the command stops instead of overwriting files.
|
|
1114
|
+
|
|
1115
|
+
Then follow the steps it prints:
|
|
1116
|
+
|
|
1117
|
+
```bash
|
|
1118
|
+
cd my-app
|
|
1119
|
+
npm install
|
|
1120
|
+
npm run dev
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
Open the address that Vite prints and type in the box. The fake server answers after 800 ms, so you can see the older request being aborted.
|
|
1124
|
+
|
|
1125
|
+
## Choose a template
|
|
1126
|
+
|
|
1127
|
+
Pass `--template` to skip the question. With npm, put `--` before the option.
|
|
1128
|
+
|
|
1129
|
+
| Template | What it shows | Use it when |
|
|
1130
|
+
| -------- | ------------- | ----------- |
|
|
1131
|
+
| `vanilla` | A bus, one event and a `debounce` plus `latest` handler, with plain DOM code | You use no framework, or want to see the bus alone |
|
|
1132
|
+
| `react` | The same handler with `useEmitter` and `useEventState`, and `usePolicy` for a debounced save in a component | You build a React app |
|
|
1133
|
+
| `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
|
|
1134
|
+
| `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
|
|
1135
|
+
| `domains-ts` | Actions with handlers, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
|
|
1136
|
+
| `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
|
|
1137
|
+
| `svelte-ts` | The `react-ts` app in Svelte 5, with `eventStore` from `nanoeffects/svelte` | You build a Svelte app |
|
|
1138
|
+
|
|
1139
|
+
```bash
|
|
1140
|
+
npm create nanoeffects@latest my-app -- --template react
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
In the `react` templates, `useEventState` keeps the found items in the component. It is not a global store: another component gets its own copy. To share a value between components, use a state manager and see [Stores](stores.md).
|
|
1144
|
+
|
|
1145
|
+
The TypeScript templates are the same apps with the same files, written in `.ts` and `.tsx`. Events are made with `createAction<Payload>`, so handlers and `useEventState` get their types from the events, with no annotation in the component. `npm run build` type-checks the project (`tsc --noEmit`) before bundling it.
|
|
1146
|
+
|
|
1147
|
+
```bash
|
|
1148
|
+
npm create nanoeffects@latest my-app -- --template react-ts
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
### Start with Vue
|
|
1152
|
+
|
|
1153
|
+
`vue-ts` is the search app of `react-ts` in Vue 3 with `<script setup lang="ts">`. Its bus uses `vuePlugin` from `nanoeffects/vue`: `watchEmitter` sends the query to the bus when it changes, and `useEventRef` keeps the result of the search in a `ref`. Like `useEventState` in React, `useEventRef` is [not a store](vue.md#it-is-not-a-store): a value that several components share belongs in a store such as Pinia.
|
|
1154
|
+
|
|
1155
|
+
```bash
|
|
1156
|
+
npm create nanoeffects@latest my-app -- --template vue-ts
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
`npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
|
|
1160
|
+
|
|
1161
|
+
### Start with Svelte
|
|
1162
|
+
|
|
1163
|
+
`svelte-ts` is the search app of `react-ts` in Svelte 5. Its bus uses `sveltePlugin` from `nanoeffects/svelte`: `eventStore` makes a Svelte store from an event. In a component, `$found` reads it, and Svelte subscribes and unsubscribes for you. The query goes to the bus from the `oninput` handler, so nothing is emitted when the component mounts. The store listens only while something reads it, and is [not a store of the app](svelte.md#it-is-not-a-store-of-the-app): a value that several components share belongs in a store of your own.
|
|
1164
|
+
|
|
1165
|
+
```bash
|
|
1166
|
+
npm create nanoeffects@latest my-app -- --template svelte-ts
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
`npm run build` type-checks the `.svelte` files with `svelte-check`, which needs TypeScript 5: the template depends on it.
|
|
1170
|
+
|
|
1171
|
+
### Start with domains
|
|
1172
|
+
|
|
1173
|
+
`domains-ts` is not the search box of the other templates grown in TypeScript: it shows where logic goes when an app has several features. Each domain is a folder under `src/domains/`. It has the events of the domain, the work that handles them, and a module that mounts the work. The React components only emit events and read values.
|
|
1174
|
+
|
|
1175
|
+
```bash
|
|
1176
|
+
npm create nanoeffects@latest my-app -- --template domains-ts
|
|
1177
|
+
```
|
|
1178
|
+
|
|
1179
|
+
| Path | Job |
|
|
1180
|
+
| ---- | --- |
|
|
1181
|
+
| `src/bus.ts` | Creates the bus. It imports no events and no domain. |
|
|
1182
|
+
| `src/app.ts` | Mounts the domains with `modulePlugin`. |
|
|
1183
|
+
| `src/domains/catalog/` | Searching products: `catalog.ts` (events and an action with a handler), `catalog-handlers.ts` (`debounce` and `latest`), `catalog-module.ts`. |
|
|
1184
|
+
| `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
|
|
1185
|
+
| `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
|
|
1186
|
+
|
|
1187
|
+
For a bigger example, a chat in which parts react to each other, see [Build a chat with entities, features, modules and a small server](chat-example.md). It has more layers than `domains-ts`: when domains must react to each other, it keeps them from importing each other by splitting them into entities and features.
|
|
1188
|
+
|
|
1189
|
+
The actions, jobs and modules themselves are explained in [Actions, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
|
|
1190
|
+
|
|
1191
|
+
## Read the files
|
|
1192
|
+
|
|
1193
|
+
Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for React components, `.vue` for Vue components, `.svelte` for Svelte components).
|
|
1194
|
+
|
|
1195
|
+
| File | Job |
|
|
1196
|
+
| ---- | --- |
|
|
1197
|
+
| `src/bus.js` | Creates the bus. It imports no events. |
|
|
1198
|
+
| `src/search.js` | Names the events with `createAction`. It imports no bus. |
|
|
1199
|
+
| `src/search-handlers.js` | Handles the events with `debounce` and `latest`. It knows the bus and the events. |
|
|
1200
|
+
| `src/api.js` | A fake server. Replace it with your own requests. |
|
|
1201
|
+
|
|
1202
|
+
Every call of nanoeffects in these files has a comment that says what it does. To make it your own, replace `src/api.js`, then rename the events and the handler.
|
|
1203
|
+
|
|
1204
|
+
## Next steps
|
|
1205
|
+
|
|
1206
|
+
- [Policies](policies.md): `leading`, `throttle` and `queue` next to `latest`.
|
|
1207
|
+
- [Build a sign-up form in React](react.md): the same ideas in a bigger React example.
|
|
1208
|
+
- [Package entries](package.md): which import holds what.
|
|
1209
|
+
|
|
1210
|
+
---
|
|
1211
|
+
Source: docs/motivation.md
|
|
1212
|
+
|
|
1213
|
+
# Why nanoeffects exists
|
|
1214
|
+
|
|
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).
|
|
1218
|
+
|
|
1219
|
+
## Moving logic out of the UI
|
|
1220
|
+
|
|
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.
|
|
1222
|
+
|
|
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).
|
|
1226
|
+
|
|
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.
|
|
1228
|
+
|
|
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.
|
|
1232
|
+
|
|
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.
|
|
1234
|
+
|
|
1235
|
+
Real cases from the libraries nanoeffects was compared with:
|
|
1236
|
+
|
|
1237
|
+
| Library | What goes wrong | Source |
|
|
1238
|
+
| ------- | --------------- | ------ |
|
|
1239
|
+
| Redux, React Redux | A shared component library uses `react-redux`. Every micro-frontend must use the same version of that library, or `react-redux` "breaks weirdly". | [react-redux#1949](https://github.com/reduxjs/react-redux/issues/1949) |
|
|
1240
|
+
| Redux | A store is reachable only under a `Provider`. Each sub-app needs its own provider, and a second one must not be created when a module is injected. Reducers duplicated between the host and a module add to it. | [Redux on Module Federation](https://dev.to/manomano-tech-team/how-to-work-with-redux-on-module-federation-13gm) |
|
|
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) |
|
|
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) |
|
|
1243
|
+
|
|
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.
|
|
1245
|
+
|
|
1246
|
+
## What the library does
|
|
1247
|
+
|
|
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.
|
|
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).
|
|
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.
|
|
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.
|
|
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.
|
|
1253
|
+
|
|
1254
|
+
---
|
|
1255
|
+
Source: docs/package.md
|
|
1256
|
+
|
|
1257
|
+
# Choose a package entry
|
|
1258
|
+
|
|
1259
|
+
> **Levels A to F.** Read first: [README](../README.md).
|
|
1260
|
+
|
|
1261
|
+
Imports are split so you only load what you use. `createBus` stays small; the rest is another entry.
|
|
1262
|
+
|
|
1263
|
+
## Start with these three
|
|
1264
|
+
|
|
1265
|
+
| Import | What is in it |
|
|
1266
|
+
| ------ | ------------- |
|
|
1267
|
+
| `nanoeffects` | `createBus`, the types |
|
|
1268
|
+
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
|
|
1269
|
+
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
1270
|
+
|
|
1271
|
+
## Add the rest when you need it
|
|
1272
|
+
|
|
1273
|
+
Each row is optional. The "when" column says what need brings you to it.
|
|
1274
|
+
|
|
1275
|
+
| Import | What is in it | When |
|
|
1276
|
+
| ------ | ------------- | ---- |
|
|
1277
|
+
| `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
|
|
1278
|
+
| `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
|
|
1279
|
+
| `nanoeffects/context` | `contextPlugin`: your own fields (a store, a client) on `ctx` of every task, typed from the value | a store or client must be on every task |
|
|
1280
|
+
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` | an outside `AbortSignal` (request, React Query) must stop tasks |
|
|
1281
|
+
| `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: the methods of a bus on a class, with the subscriptions of each instance ended by `dispose()` | you write classes, not functions |
|
|
1282
|
+
| `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
|
|
1283
|
+
| `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
|
|
1284
|
+
| `nanoeffects/observable` | `createObserve`, `Subject` | you need an Observable (RxJS) |
|
|
1285
|
+
| `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
|
|
1286
|
+
| `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
|
|
1287
|
+
| `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
|
|
1288
|
+
| `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
|
|
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 |
|
|
1291
|
+
|
|
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.
|
|
1293
|
+
|
|
1294
|
+
## Give the docs to an AI assistant
|
|
1295
|
+
|
|
1296
|
+
The package ships plain-text files for assistants that write code with it:
|
|
1297
|
+
|
|
1298
|
+
- `llms.txt`: a short index. It states the rules that generated code often gets wrong and links to every page of these docs.
|
|
1299
|
+
- `llms-full.txt`: the same summary followed by the text of every linked page, in one file.
|
|
1300
|
+
- `skills/nanoeffects/SKILL.md`: a skill for assistants that support skills (for example Claude Code). It says when to suggest nanoeffects, when not to, and the same rules. Copy the folder to `.claude/skills/` in your project or to `~/.claude/skills/`.
|
|
1301
|
+
|
|
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.
|
|
1303
|
+
|
|
1304
|
+
---
|
|
1305
|
+
Source: docs/plugins.md
|
|
1306
|
+
|
|
1307
|
+
# Choose the plugins of a bus
|
|
1308
|
+
|
|
1309
|
+
> **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
|
|
1310
|
+
|
|
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.
|
|
1312
|
+
|
|
1313
|
+
## On this page
|
|
1314
|
+
|
|
1315
|
+
- [Use a plugin](#use-a-plugin)
|
|
1316
|
+
- [Pick a plugin by task](#pick-a-plugin-by-task)
|
|
1317
|
+
- [Main plugins](#main-plugins)
|
|
1318
|
+
- [Put a store or a client on every handler: `contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin)
|
|
1319
|
+
- [Use `race` and `delay` without `ctx`: `effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin)
|
|
1320
|
+
- [Bind or replace a handler: `actionsPlugin`](#bind-or-replace-a-handler-actionsplugin)
|
|
1321
|
+
- [Mount a domain as one unit: `modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin)
|
|
1322
|
+
- [Extra plugins](#extra-plugins)
|
|
1323
|
+
- [Give one action its own fields: `scopePlugin`](#give-one-action-its-own-fields-scopeplugin)
|
|
1324
|
+
- [Tie an emit or a listener to an outside signal: `signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin)
|
|
1325
|
+
- [Name the origin and the flow of a trace: `tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin)
|
|
1326
|
+
- [Listen to a group of events: `matchPlugin`](#listen-to-a-group-of-events-matchplugin)
|
|
1327
|
+
- [Hand the bus to a state manager: `emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin)
|
|
1328
|
+
- [Tell the bus when a React value changes: `reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin)
|
|
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)
|
|
1331
|
+
- [Put them together](#put-them-together)
|
|
1332
|
+
- [Use them in any order](#use-them-in-any-order)
|
|
1333
|
+
- [See what is not a plugin](#see-what-is-not-a-plugin)
|
|
1334
|
+
- [Write your own plugin](#write-your-own-plugin)
|
|
1335
|
+
|
|
1336
|
+
## Use a plugin
|
|
1337
|
+
|
|
1338
|
+
A plugin is a function of the bus. `bus.use(plugin)` calls it with the bus and returns whatever the plugin returns. Most plugins return the same bus, so you can chain them: `createBus().use(a).use(b)`. A few return something of their own (a registry, an app, a `disconnect` function): keep it.
|
|
1339
|
+
|
|
1340
|
+
In TypeScript keep the result of `contextPlugin` and `effectsPlugin`: the type of the bus with its new fields is the type they return (`const bus = createBus().use(contextPlugin({ store }))`).
|
|
1341
|
+
|
|
1342
|
+
## Pick a plugin by task
|
|
1343
|
+
|
|
1344
|
+
Find your need in the left column. No row fits: you need no plugin.
|
|
1345
|
+
|
|
1346
|
+
| I need | Use |
|
|
1347
|
+
| ------ | --- |
|
|
1348
|
+
| A store, client or logger on every handler | [`contextPlugin`](#put-a-store-or-a-client-on-every-handler-contextplugin) |
|
|
1349
|
+
| `race`, `all` or `delay` without passing `ctx` | [`effectsPlugin`](#use-race-and-delay-without-ctx-effectsplugin) |
|
|
1350
|
+
| A test that replaces a handler | [`actionsPlugin`](#bind-or-replace-a-handler-actionsplugin) |
|
|
1351
|
+
| One domain mounted and unmounted as a unit | [`modulePlugin`](#mount-a-domain-as-one-unit-moduleplugin) |
|
|
1352
|
+
| Fields for one action or module only | [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) |
|
|
1353
|
+
| Tasks that stop when a request, socket or query is cancelled | [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) |
|
|
1354
|
+
| One listener for a group of events | [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) |
|
|
1355
|
+
| A callback for a state manager that emits | [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) |
|
|
1356
|
+
| A React component that sends its value to the bus | [`reactPlugin`](#tell-the-bus-when-a-react-value-changes-reactplugin) |
|
|
1357
|
+
| Names in the trace | [`tracePlugin`](#name-the-origin-and-the-flow-of-a-trace-traceplugin) |
|
|
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) |
|
|
1360
|
+
|
|
1361
|
+
## Main plugins
|
|
1362
|
+
|
|
1363
|
+
These four cover what most apps need: shared dependencies, effects, handlers and modules. Start here.
|
|
840
1364
|
|
|
841
1365
|
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
842
1366
|
| ------ | ----------------- | ------ | ----------------- |
|
|
843
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 |
|
|
844
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 |
|
|
845
|
-
| [`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 }` |
|
|
846
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 }` |
|
|
847
1371
|
|
|
848
1372
|
### Put a store or a client on every handler: `contextPlugin`
|
|
@@ -859,9 +1383,11 @@ const bus = createBus().use(contextPlugin({ store }));
|
|
|
859
1383
|
bus.on("save", (draft, { store }) => store.put(draft));
|
|
860
1384
|
```
|
|
861
1385
|
|
|
1386
|
+
A client that must stop with the task, such as an `axios` instance, goes in as `fromSignal((signal) => axios.create({ signal }))` from `nanoeffects/signal`: it is built from the signal of each task, when the task reads it. More in [Build a field from the signal of each task](context.md#build-a-field-from-the-signal-of-each-task).
|
|
1387
|
+
|
|
862
1388
|
### Use `race` and `delay` without `ctx`: `effectsPlugin`
|
|
863
1389
|
|
|
864
|
-
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).
|
|
865
1391
|
|
|
866
1392
|
```js
|
|
867
1393
|
import { createBus } from "nanoeffects";
|
|
@@ -874,7 +1400,7 @@ const bus = createBus().use(effects);
|
|
|
874
1400
|
|
|
875
1401
|
bus.on("load", async (id, { race, delay }) => {
|
|
876
1402
|
const result = await race({
|
|
877
|
-
data: (
|
|
1403
|
+
data: (child) => api.get(id, child),
|
|
878
1404
|
timeout: () => delay(5000),
|
|
879
1405
|
});
|
|
880
1406
|
});
|
|
@@ -900,7 +1426,7 @@ const user = await bus.emit(getUser("1")); // { id: "1" }
|
|
|
900
1426
|
|
|
901
1427
|
### Mount a domain as one unit: `modulePlugin`
|
|
902
1428
|
|
|
903
|
-
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).
|
|
904
1430
|
|
|
905
1431
|
```js
|
|
906
1432
|
import { createBus } from "nanoeffects";
|
|
@@ -911,7 +1437,7 @@ const bus = createBus();
|
|
|
911
1437
|
// modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
|
|
912
1438
|
const app = bus.use(modulePlugin([userModule]));
|
|
913
1439
|
|
|
914
|
-
app.unmount(userModule); // its listeners, bound
|
|
1440
|
+
app.unmount(userModule); // its listeners, bound actions and tasks are gone
|
|
915
1441
|
```
|
|
916
1442
|
|
|
917
1443
|
## Extra plugins
|
|
@@ -921,26 +1447,27 @@ Add one of these when you meet its need. None of them is required.
|
|
|
921
1447
|
| Plugin | What it gives you | Import | `bus.use` returns |
|
|
922
1448
|
| ------ | ----------------- | ------ | ----------------- |
|
|
923
1449
|
| [`scopePlugin`](#give-one-action-its-own-fields-scopeplugin) | Its own fields for one action, job or module, and a way to replace them in one `emit`. | `nanoeffects/scope` | The same bus |
|
|
924
|
-
| [`signalPlugin`](#tie-an-emit-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
|
|
1450
|
+
| [`signalPlugin`](#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin) | `emit(call, { signal })` that aborts its tasks, and `on(event, handler, { signal })` that removes the listener, when an outside signal aborts. | `nanoeffects/signal` | The same bus, typed with the new `emit` |
|
|
925
1451
|
| [`matchPlugin`](#listen-to-a-group-of-events-matchplugin) | `on` that listens to a group of events which a function picks. | `nanoeffects/match` | The same bus, typed with the new overloads |
|
|
926
1452
|
| [`emitterPlugin`](#hand-the-bus-to-a-state-manager-emitterplugin) | A callback that emits an action, to give to a state manager. | `nanoeffects/actions` | The same bus, with `emitter` |
|
|
927
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 |
|
|
928
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` |
|
|
929
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 |
|
|
930
1457
|
|
|
931
1458
|
### Give one action its own fields: `scopePlugin`
|
|
932
1459
|
|
|
933
|
-
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).
|
|
934
1461
|
|
|
935
1462
|
```js
|
|
936
1463
|
import { createBus } from "nanoeffects";
|
|
937
1464
|
import { createAction } from "nanoeffects/actions";
|
|
938
1465
|
import { scopePlugin } from "nanoeffects/scope";
|
|
939
1466
|
|
|
940
|
-
// scopePlugin — makes the scope of
|
|
1467
|
+
// scopePlugin — makes the scope of an action work on this bus
|
|
941
1468
|
const bus = createBus().use(scopePlugin());
|
|
942
1469
|
|
|
943
|
-
// the last argument: fields of this
|
|
1470
|
+
// the last argument: fields of this action only
|
|
944
1471
|
const getUser = createAction("user/get", (id, { api }) => api.getUser(id), {
|
|
945
1472
|
api,
|
|
946
1473
|
});
|
|
@@ -949,25 +1476,41 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
|
949
1476
|
bus.emit(getUser("1"), { api: fakeApi }); // a fake api for this call only
|
|
950
1477
|
```
|
|
951
1478
|
|
|
952
|
-
### Tie an emit to an outside signal: `signalPlugin`
|
|
1479
|
+
### Tie an emit or a listener to an outside signal: `signalPlugin`
|
|
953
1480
|
|
|
954
|
-
A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query and
|
|
1481
|
+
A library such as React Query owns the request and gives you an `AbortSignal`. `emit` takes it as `{ signal }` and aborts the tasks it starts when the signal aborts, so `timeout`, `retry` and the request stop too. More in [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
|
|
955
1482
|
|
|
956
1483
|
```js
|
|
957
1484
|
import { createBus } from "nanoeffects";
|
|
958
1485
|
import { createAction } from "nanoeffects/actions";
|
|
959
1486
|
import { signalPlugin } from "nanoeffects/signal";
|
|
960
1487
|
|
|
961
|
-
// signalPlugin — emit takes { signal } for
|
|
1488
|
+
// signalPlugin — emit takes { signal } for an action or a job
|
|
962
1489
|
const bus = createBus().use(signalPlugin());
|
|
963
1490
|
|
|
964
1491
|
const getUser = createAction("user/get", (id, { signal }) => api.getUser(id, { signal }));
|
|
965
1492
|
|
|
966
1493
|
const controller = new AbortController();
|
|
967
|
-
bus.emit(getUser("1"), { signal: controller.signal }); // runs the
|
|
1494
|
+
bus.emit(getUser("1"), { signal: controller.signal }); // runs the action
|
|
968
1495
|
controller.abort(); // ctx.signal of the handler aborts with it
|
|
969
1496
|
```
|
|
970
1497
|
|
|
1498
|
+
The same signal can end a listener. `on` and `once` take `{ signal }` as their options, as `addEventListener` does, so code that has a signal keeps no `unbind` function. The listener is removed when the signal aborts; a handler that already runs is not aborted. A signal that is aborted already adds no listener.
|
|
1499
|
+
|
|
1500
|
+
```js
|
|
1501
|
+
import { createBus } from "nanoeffects";
|
|
1502
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
1503
|
+
|
|
1504
|
+
// signalPlugin — on and once take { signal } in their options
|
|
1505
|
+
const bus = createBus().use(signalPlugin());
|
|
1506
|
+
|
|
1507
|
+
const controller = new AbortController();
|
|
1508
|
+
bus.on("saved", (draft) => console.log(draft), { signal: controller.signal }); // listens until the abort
|
|
1509
|
+
controller.abort(); // the listener is gone: bus.get("saved") is empty
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
Without the plugin the `signal` option is ignored and the listener stays. `off(event, handler)` removes a listener but does not end its link to the signal; call the function that `on` returned for that.
|
|
1513
|
+
|
|
971
1514
|
### Name the origin and the flow of a trace: `tracePlugin`
|
|
972
1515
|
|
|
973
1516
|
With a tracer on the bus, the plugin lets a trace say which subscription began a chain (`traceId` of `useEmitter` and `bus.emitter`), follow one flow by a `traceGroupId`, and show which listener ran (`bus.on(event, handler, { traceId })`). Without the plugin the options are ignored. More in [Tracing](tracing.md#show-which-subscription-began-a-chain).
|
|
@@ -1059,6 +1602,18 @@ const disconnect = bus.use(devPlugin());
|
|
|
1059
1602
|
disconnect(); // stops the diagnostics
|
|
1060
1603
|
```
|
|
1061
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
|
+
|
|
1062
1617
|
## Put them together
|
|
1063
1618
|
|
|
1064
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.
|
|
@@ -1080,7 +1635,7 @@ import { scopePlugin } from "nanoeffects/scope";
|
|
|
1080
1635
|
export const bus = createBus()
|
|
1081
1636
|
.use(contextPlugin({ store })) // ctx.store on every task
|
|
1082
1637
|
.use(effectsPlugin({ race, all })) // ctx.race and ctx.all, bound to the task
|
|
1083
|
-
.use(scopePlugin()) // scopes for
|
|
1638
|
+
.use(scopePlugin()) // scopes for actions, jobs, modules and emit
|
|
1084
1639
|
.use(matchPlugin()) // bus.on takes a function that picks the events
|
|
1085
1640
|
.use(emitterPlugin()); // bus.emitter(creator)
|
|
1086
1641
|
|
|
@@ -1121,7 +1676,7 @@ These connect the bus to something, but are not used with `bus.use`:
|
|
|
1121
1676
|
- `createEffectsMiddleware(bus, { filter })` from `nanoeffects/redux` is a Redux middleware for a bus that exists. See [Stores](stores.md#wire-redux-toolkit-to-a-bus).
|
|
1122
1677
|
- `createBus({ trace })` takes a tracer from `nanoeffects/trace`. See [Tracing](tracing.md).
|
|
1123
1678
|
- `extendBus(bus, Base)` from `nanoeffects/oop` gives a class the methods of a bus, with the subscriptions of each instance ended by `dispose()`. It reads the bus as it is, so use the plugins first. See [Use the bus from a class](oop.md).
|
|
1124
|
-
- `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable. See [Observable](observable.md).
|
|
1679
|
+
- `createObserve(bus)` from `nanoeffects/observable` turns an event into an Observable, and `events(bus, event)` into a `for await` loop. See [Observable](observable.md).
|
|
1125
1680
|
|
|
1126
1681
|
## Write your own plugin
|
|
1127
1682
|
|
|
@@ -1157,11 +1712,13 @@ Source: docs/policies.md
|
|
|
1157
1712
|
|
|
1158
1713
|
# Policies
|
|
1159
1714
|
|
|
1715
|
+
> **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
1716
|
+
|
|
1160
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.
|
|
1161
1718
|
|
|
1162
1719
|
Start with `latest`. The table and sections below are the rest of the reference.
|
|
1163
1720
|
|
|
1164
|
-
##
|
|
1721
|
+
## `latest`: abort the previous run
|
|
1165
1722
|
|
|
1166
1723
|
```js
|
|
1167
1724
|
import { createBus } from "nanoeffects";
|
|
@@ -1183,7 +1740,7 @@ Pass `signal` to `fetch` (and the like) so the request cancels.
|
|
|
1183
1740
|
|
|
1184
1741
|
## Choose a policy
|
|
1185
1742
|
|
|
1186
|
-
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)).
|
|
1187
1744
|
|
|
1188
1745
|
| Policy | Overlapping calls |
|
|
1189
1746
|
| ------ | ----------------- |
|
|
@@ -1193,54 +1750,79 @@ Every policy accepts `key` in its options (see [Isolate state by key](#isolate-s
|
|
|
1193
1750
|
| `throttle(ms, fn, options?)` | at most one run per `ms` |
|
|
1194
1751
|
| `queue(fn, options?)` | one after another, in order |
|
|
1195
1752
|
|
|
1196
|
-
|
|
1753
|
+
Each policy has a short entry in [Look up a policy](#look-up-a-policy).
|
|
1197
1754
|
|
|
1198
|
-
|
|
1755
|
+
## Look up a policy
|
|
1199
1756
|
|
|
1200
|
-
|
|
1757
|
+
One entry per policy: what it does, and a link to the full recipe.
|
|
1201
1758
|
|
|
1202
|
-
|
|
1759
|
+
### `latest`
|
|
1203
1760
|
|
|
1204
|
-
|
|
1205
|
-
import { debounce } from "nanoeffects/policies";
|
|
1761
|
+
A newer call aborts the older one and drops its result.
|
|
1206
1762
|
|
|
1207
|
-
|
|
1208
|
-
await api.search(query);
|
|
1209
|
-
};
|
|
1763
|
+
Signature: `latest(fn, options?)`.
|
|
1210
1764
|
|
|
1211
|
-
|
|
1212
|
-
const wrap = debounce(300);
|
|
1765
|
+
More: [`latest`: abort the previous run](#latest-abort-the-previous-run).
|
|
1213
1766
|
|
|
1214
|
-
|
|
1215
|
-
const search = wrap(runSearch);
|
|
1216
|
-
```
|
|
1767
|
+
### `leading`
|
|
1217
1768
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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).
|
|
1222
1808
|
|
|
1223
1809
|
## Configure debounce and throttle
|
|
1224
1810
|
|
|
1225
|
-
`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.
|
|
1226
1812
|
|
|
1227
|
-
**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`.
|
|
1228
1814
|
|
|
1229
|
-
-
|
|
1230
|
-
-
|
|
1231
|
-
- Calls made while it waits are held; the latest runs when the window closes.
|
|
1232
|
-
- Under a steady stream it runs every `ms`.
|
|
1233
|
-
- `leading: false` — even the first call waits for the end of its window.
|
|
1234
|
-
- `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.
|
|
1235
1817
|
|
|
1236
|
-
**Debounce** (`debounce(ms, fn)`)
|
|
1818
|
+
**Debounce** (`debounce(ms, fn)`) runs the last call of a series after `ms` of quiet.
|
|
1237
1819
|
|
|
1238
|
-
-
|
|
1239
|
-
- `leading
|
|
1240
|
-
- `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.
|
|
1241
1823
|
- Both `false` — nothing runs.
|
|
1242
1824
|
|
|
1243
|
-
##
|
|
1825
|
+
## `key`: isolate state by key
|
|
1244
1826
|
|
|
1245
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).
|
|
1246
1828
|
|
|
@@ -1299,44 +1881,46 @@ Every call returns a promise with the result of its own run. An error rejects on
|
|
|
1299
1881
|
- When a running call aborts, its slot is freed at once and its result is dropped.
|
|
1300
1882
|
- Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
|
|
1301
1883
|
|
|
1302
|
-
##
|
|
1884
|
+
## `withPolicy`: call a policy outside a bus
|
|
1303
1885
|
|
|
1304
|
-
**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.
|
|
1305
1887
|
|
|
1306
|
-
**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.
|
|
1307
1889
|
|
|
1308
|
-
`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.
|
|
1309
1891
|
|
|
1310
1892
|
```js
|
|
1311
1893
|
import { debounce, withPolicy } from "nanoeffects/policies";
|
|
1312
1894
|
|
|
1895
|
+
const input = document.querySelector("#search");
|
|
1896
|
+
|
|
1313
1897
|
// debounce — waits for 300 ms of quiet, then runs the callback
|
|
1314
1898
|
const policy = debounce(300, async (query, { signal }) => {
|
|
1315
|
-
await api.search(query, { signal });
|
|
1899
|
+
const items = await api.search(query, { signal });
|
|
1900
|
+
render(items);
|
|
1316
1901
|
});
|
|
1317
1902
|
|
|
1318
1903
|
// withPolicy — a plain function: no bus, no context argument
|
|
1319
|
-
const
|
|
1904
|
+
const onInput = withPolicy(policy);
|
|
1320
1905
|
|
|
1321
|
-
|
|
1322
|
-
onChange("redux"); // only this call runs, 300 ms after it
|
|
1906
|
+
input.addEventListener("input", (event) => onInput(event.target.value));
|
|
1323
1907
|
|
|
1324
|
-
// cancel — drops a pending call and aborts a running
|
|
1325
|
-
|
|
1908
|
+
// cancel — drops a pending call and aborts a running request, for example when the page closes
|
|
1909
|
+
window.addEventListener("pagehide", () => onInput.cancel());
|
|
1326
1910
|
```
|
|
1327
1911
|
|
|
1328
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.
|
|
1329
1913
|
- Every call returns a promise with the result. A call the policy drops resolves with `undefined`.
|
|
1330
1914
|
- `cancel()` aborts every call that has not finished. Calls made after it work again.
|
|
1331
|
-
- Where `Symbol.dispose` exists, `using
|
|
1332
|
-
|
|
1333
|
-
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.
|
|
1334
1916
|
|
|
1335
1917
|
---
|
|
1336
1918
|
Source: docs/cancel-stale-requests.md
|
|
1337
1919
|
|
|
1338
1920
|
# Cancel stale requests and fix race conditions
|
|
1339
1921
|
|
|
1922
|
+
> **Level B.** Read first: the bus from the [README](../README.md).
|
|
1923
|
+
|
|
1340
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).
|
|
1341
1925
|
|
|
1342
1926
|
## See the problem
|
|
@@ -1454,7 +2038,9 @@ All policies and their options: [Policies](policies.md). The same step by step i
|
|
|
1454
2038
|
---
|
|
1455
2039
|
Source: docs/debounce-and-abort.md
|
|
1456
2040
|
|
|
1457
|
-
#
|
|
2041
|
+
# Pause between requests
|
|
2042
|
+
|
|
2043
|
+
> **Level B.** Read first: [Policies](policies.md).
|
|
1458
2044
|
|
|
1459
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.
|
|
1460
2046
|
|
|
@@ -1564,6 +2150,8 @@ Source: docs/prevent-double-submit.md
|
|
|
1564
2150
|
|
|
1565
2151
|
# Prevent a double submit
|
|
1566
2152
|
|
|
2153
|
+
> **Level B.** Read first: [Policies](policies.md).
|
|
2154
|
+
|
|
1567
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.
|
|
1568
2156
|
|
|
1569
2157
|
## See the problem
|
|
@@ -1658,7 +2246,7 @@ bus.on(
|
|
|
1658
2246
|
);
|
|
1659
2247
|
```
|
|
1660
2248
|
|
|
1661
|
-
Backoff between tries: [
|
|
2249
|
+
Backoff between tries: [`backoff`](effects.md#backoff).
|
|
1662
2250
|
|
|
1663
2251
|
## Guard a handler when you have no bus
|
|
1664
2252
|
|
|
@@ -1696,110 +2284,156 @@ Source: docs/effects.md
|
|
|
1696
2284
|
|
|
1697
2285
|
# Effects and cancellation
|
|
1698
2286
|
|
|
1699
|
-
|
|
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.
|
|
1700
2290
|
|
|
1701
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)).
|
|
1702
2292
|
|
|
1703
|
-
##
|
|
2293
|
+
## Effects
|
|
1704
2294
|
|
|
1705
|
-
|
|
2295
|
+
Every effect below has its own section with its forms, options and an example. `ctx` is the context of the task.
|
|
1706
2296
|
|
|
1707
|
-
-
|
|
1708
|
-
-
|
|
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.
|
|
1709
2311
|
|
|
1710
|
-
|
|
2312
|
+
## `retry`
|
|
2313
|
+
|
|
2314
|
+
Runs a function again after an error. Use it when a call fails now and then, for example because of the network.
|
|
2315
|
+
|
|
2316
|
+
Forms:
|
|
2317
|
+
|
|
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.
|
|
2320
|
+
|
|
2321
|
+
Options:
|
|
2322
|
+
|
|
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.
|
|
1711
2328
|
|
|
1712
2329
|
```js
|
|
1713
2330
|
import { createBus } from "nanoeffects";
|
|
1714
|
-
import {
|
|
2331
|
+
import { retry } from "nanoeffects/effects";
|
|
1715
2332
|
import { latest } from "nanoeffects/policies";
|
|
1716
2333
|
|
|
1717
2334
|
const bus = createBus();
|
|
1718
2335
|
const loadUser = (id, { signal }) => fetchUser(id, { signal });
|
|
1719
2336
|
|
|
1720
|
-
//
|
|
1721
|
-
const
|
|
1722
|
-
// retry — up to 4 attempts with backoff
|
|
1723
|
-
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, {
|
|
1724
2339
|
times: 4,
|
|
1725
|
-
delay: (attempt) => 2 ** attempt * 100,
|
|
1726
2340
|
shouldRetry: (error) => !isFatal(error),
|
|
1727
2341
|
});
|
|
1728
|
-
// latest — a newer load aborts the older retry chain
|
|
1729
|
-
bus.on("load", latest(loadWithRetry));
|
|
1730
2342
|
|
|
1731
|
-
//
|
|
1732
|
-
bus.on("
|
|
2343
|
+
// latest — a newer load aborts the older chain of attempts
|
|
2344
|
+
bus.on("load", latest(loadWithRetry));
|
|
1733
2345
|
|
|
1734
|
-
// ctx.run — run
|
|
2346
|
+
// ctx.run — run the wrapped function as a child task (aborted with the handler)
|
|
1735
2347
|
bus.on("open", async (id, ctx) => {
|
|
1736
|
-
const
|
|
1737
|
-
const user = await ctx.run(load, id);
|
|
2348
|
+
const user = await ctx.run(loadWithRetry, id);
|
|
1738
2349
|
ctx.emit("opened", user);
|
|
1739
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();
|
|
1740
2360
|
|
|
1741
|
-
// ctx-first form — one call inside a body
|
|
1742
2361
|
bus.on("load", async (id, ctx) => {
|
|
2362
|
+
// retry with ctx — one call inside the body
|
|
1743
2363
|
const user = await retry(
|
|
1744
2364
|
ctx,
|
|
1745
2365
|
() => fetchUser(id, { signal: ctx.signal }),
|
|
1746
2366
|
{ times: 4 },
|
|
1747
2367
|
);
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
});
|
|
1751
|
-
await poll(ctx, () => refresh(), 10_000);
|
|
1752
|
-
ctx.emit("ready", { user, data });
|
|
2368
|
+
|
|
2369
|
+
ctx.emit("loaded", user);
|
|
1753
2370
|
});
|
|
1754
2371
|
```
|
|
1755
2372
|
|
|
1756
|
-
|
|
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.
|
|
1757
2381
|
|
|
1758
|
-
|
|
2382
|
+
The error is a `DOMException` named `TimeoutError` (like `AbortSignal.timeout`). `timeout` rejects even if the function ignores its signal.
|
|
1759
2383
|
|
|
1760
|
-
|
|
2384
|
+
```js
|
|
2385
|
+
import { createBus } from "nanoeffects";
|
|
2386
|
+
import { timeout } from "nanoeffects/effects";
|
|
1761
2387
|
|
|
1762
|
-
|
|
2388
|
+
const bus = createBus();
|
|
2389
|
+
const loadUser = (id, { signal }) => fetchUser(id, { signal });
|
|
1763
2390
|
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
- `shouldRetry(error, attempt)` — stop early.
|
|
1767
|
-
- `fn` gets the attempt number starting at `1`.
|
|
1768
|
-
- On abort, rejects with the abort reason.
|
|
2391
|
+
// timeout — a wrapper of the handler: it must finish within 5 s
|
|
2392
|
+
bus.on("load", timeout(5000, loadUser));
|
|
1769
2393
|
|
|
1770
|
-
|
|
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
|
+
```
|
|
1771
2400
|
|
|
1772
|
-
|
|
1773
|
-
- Rejects even if the starter ignores its signal.
|
|
2401
|
+
## `poll`
|
|
1774
2402
|
|
|
1775
|
-
|
|
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.
|
|
1776
2404
|
|
|
1777
|
-
|
|
1778
|
-
- Next ticks keep a steady rate whatever the duration of `fn`, so a slow `fn` can overlap the next call.
|
|
1779
|
-
- `waitForFn: true` starts each pause when the call ends — calls never overlap.
|
|
1780
|
-
- `fn` gets the call number from `0`.
|
|
1781
|
-
- An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
|
|
2405
|
+
Forms:
|
|
1782
2406
|
|
|
1783
|
-
|
|
2407
|
+
- `poll(ms, fn, options?)` — a wrapper of a handler.
|
|
2408
|
+
- `poll(ctx, fn, ms)` — a call inside a body.
|
|
2409
|
+
|
|
2410
|
+
Options:
|
|
2411
|
+
|
|
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`.
|
|
2415
|
+
- An error from `fn` stops polling and rejects. An abort ends it normally and clears the timer.
|
|
1784
2416
|
|
|
1785
2417
|
```js
|
|
1786
2418
|
import { createBus } from "nanoeffects";
|
|
1787
|
-
import {
|
|
2419
|
+
import { poll } from "nanoeffects/effects";
|
|
1788
2420
|
|
|
1789
2421
|
const bus = createBus();
|
|
1790
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
|
+
|
|
1791
2426
|
bus.on("sync", async (_, ctx) => {
|
|
1792
|
-
|
|
1793
|
-
poll(ctx,
|
|
1794
|
-
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);
|
|
1795
2429
|
});
|
|
1796
2430
|
```
|
|
1797
2431
|
|
|
1798
|
-
|
|
2432
|
+
`retry`, `timeout` and `poll` are traced: the effect is a group, and each attempt or call is a `run` child.
|
|
1799
2433
|
|
|
1800
|
-
##
|
|
2434
|
+
## `backoff`
|
|
1801
2435
|
|
|
1802
|
-
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`.
|
|
1803
2437
|
|
|
1804
2438
|
The shortest form is a `delay` function. It needs no import:
|
|
1805
2439
|
|
|
@@ -1839,48 +2473,41 @@ const loadWithRetry = retry(loadUser, {
|
|
|
1839
2473
|
- `retry` counts attempts from 1. `poll` counts calls from 0, so with `poll` the first pause is `base / factor`.
|
|
1840
2474
|
- The result is a plain function `(attempt) => ms`, so it also fits `delay(ctx, …)`.
|
|
1841
2475
|
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
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:
|
|
1845
2477
|
|
|
1846
2478
|
```js
|
|
1847
2479
|
import { createBus } from "nanoeffects";
|
|
1848
|
-
import {
|
|
1849
|
-
|
|
1850
|
-
// effectsPlugin — add race, all and delay to the context of every task
|
|
1851
|
-
const effects = effectsPlugin({ race, all, delay });
|
|
1852
|
-
|
|
1853
|
-
// use returns the same bus, typed with the new fields
|
|
1854
|
-
const bus = createBus().use(effects);
|
|
2480
|
+
import { delay, poll, retry } from "nanoeffects/effects";
|
|
1855
2481
|
|
|
1856
|
-
bus
|
|
1857
|
-
// race — same as race(ctx, …)
|
|
1858
|
-
const result = await race({
|
|
1859
|
-
data: ({ signal }) => fetchData(id, { signal }),
|
|
1860
|
-
// a starter gets its own child with the same bound effects, so delay(5000) stops with the race
|
|
1861
|
-
timeout: ({ delay }) => delay(5000),
|
|
1862
|
-
});
|
|
1863
|
-
if ("timeout" in result) throw new Error("timed out");
|
|
2482
|
+
const bus = createBus();
|
|
1864
2483
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
});
|
|
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
|
|
1870
2488
|
});
|
|
1871
2489
|
```
|
|
1872
2490
|
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
2491
|
+
## `delay`
|
|
2492
|
+
|
|
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.
|
|
2494
|
+
|
|
2495
|
+
```js
|
|
2496
|
+
import { createBus } from "nanoeffects";
|
|
2497
|
+
import { delay } from "nanoeffects/effects";
|
|
2498
|
+
|
|
2499
|
+
const bus = createBus();
|
|
1878
2500
|
|
|
1879
|
-
|
|
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
|
+
```
|
|
1880
2507
|
|
|
1881
|
-
|
|
2508
|
+
## `cancellable`
|
|
1882
2509
|
|
|
1883
|
-
|
|
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 }`.
|
|
1884
2511
|
|
|
1885
2512
|
```js
|
|
1886
2513
|
import { createBus } from "nanoeffects";
|
|
@@ -1889,7 +2516,7 @@ import { latest } from "nanoeffects/policies";
|
|
|
1889
2516
|
|
|
1890
2517
|
const bus = createBus();
|
|
1891
2518
|
|
|
1892
|
-
// cancellable —
|
|
2519
|
+
// cancellable — a Promise tied to the task signal; onCancel registers a cleanup
|
|
1893
2520
|
// latest — a newer wait aborts the older timer
|
|
1894
2521
|
bus.on(
|
|
1895
2522
|
"wait",
|
|
@@ -1902,13 +2529,13 @@ bus.on(
|
|
|
1902
2529
|
);
|
|
1903
2530
|
```
|
|
1904
2531
|
|
|
1905
|
-
- 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.
|
|
1906
2533
|
- If the task is already aborted, the executor does not run.
|
|
1907
|
-
- After the
|
|
2534
|
+
- After the Promise settles, the abort listener is removed and cleanups are dropped.
|
|
1908
2535
|
|
|
1909
|
-
##
|
|
2536
|
+
## `onAbort`
|
|
1910
2537
|
|
|
1911
|
-
`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.
|
|
1912
2539
|
|
|
1913
2540
|
```js
|
|
1914
2541
|
import { createBus } from "nanoeffects";
|
|
@@ -1918,67 +2545,124 @@ const bus = createBus();
|
|
|
1918
2545
|
|
|
1919
2546
|
bus.on("connect", (url, ctx) => {
|
|
1920
2547
|
const socket = new WebSocket(url);
|
|
1921
|
-
// onAbort —
|
|
2548
|
+
// onAbort — close the socket when this task aborts
|
|
1922
2549
|
onAbort(ctx, () => socket.close());
|
|
1923
2550
|
});
|
|
1924
2551
|
```
|
|
1925
2552
|
|
|
1926
|
-
##
|
|
2553
|
+
## `ctx.on` and `ctx.off`
|
|
2554
|
+
|
|
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.
|
|
2556
|
+
|
|
2557
|
+
```js
|
|
2558
|
+
import { createBus } from "nanoeffects";
|
|
2559
|
+
|
|
2560
|
+
const bus = createBus();
|
|
2561
|
+
|
|
2562
|
+
bus.on("watch", (_, ctx) => {
|
|
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);
|
|
2568
|
+
});
|
|
2569
|
+
```
|
|
2570
|
+
|
|
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.
|
|
2582
|
+
|
|
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`.
|
|
2586
|
+
|
|
2587
|
+
```js
|
|
2588
|
+
import { createBus } from "nanoeffects";
|
|
2589
|
+
import { createAction } from "nanoeffects/actions";
|
|
2590
|
+
import { take, timeout } from "nanoeffects/effects";
|
|
2591
|
+
|
|
2592
|
+
const bus = createBus();
|
|
2593
|
+
const stopRequested = createAction("stop"); // an event with a name; calling it builds the action
|
|
2594
|
+
|
|
2595
|
+
bus.on("login", async (user, ctx) => {
|
|
2596
|
+
const reason = await take(ctx, "logout"); // resolves on the next logout
|
|
2597
|
+
cleanup(user, reason);
|
|
2598
|
+
|
|
2599
|
+
// an action creator works the same, and its payload type is the type of the result
|
|
2600
|
+
const stopped = await take(ctx, stopRequested);
|
|
2601
|
+
console.log(stopped);
|
|
2602
|
+
|
|
2603
|
+
// wait for a matching event, but not longer than 5 s
|
|
2604
|
+
const id = await timeout(
|
|
2605
|
+
ctx,
|
|
2606
|
+
5000,
|
|
2607
|
+
take("saved", { predicate: (savedId) => savedId === draft.id }), // take without ctx — a ready-made function for the group
|
|
2608
|
+
);
|
|
2609
|
+
});
|
|
2610
|
+
```
|
|
2611
|
+
|
|
2612
|
+
- It sees only events emitted after the call, takes one, and unsubscribes.
|
|
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.
|
|
2614
|
+
- `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
|
|
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`
|
|
1927
2619
|
|
|
1928
|
-
|
|
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.
|
|
1929
2621
|
|
|
1930
2622
|
```js
|
|
1931
2623
|
import { createBus } from "nanoeffects";
|
|
2624
|
+
import { all } from "nanoeffects/effects";
|
|
1932
2625
|
|
|
1933
2626
|
const bus = createBus();
|
|
1934
2627
|
|
|
1935
|
-
bus.on("
|
|
1936
|
-
|
|
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 });
|
|
1937
2636
|
});
|
|
1938
2637
|
```
|
|
1939
2638
|
|
|
1940
|
-
|
|
1941
|
-
- Under `latest`, subscriptions of a superseded run end with that run.
|
|
1942
|
-
- `ctx.off(event)` clears every handler of the event on the bus, not only the task’s.
|
|
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`.
|
|
1943
2640
|
|
|
1944
|
-
|
|
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:
|
|
1945
2642
|
|
|
1946
2643
|
```js
|
|
1947
2644
|
import { createBus } from "nanoeffects";
|
|
1948
|
-
import {
|
|
1949
|
-
import { take, timeout } from "nanoeffects/effects";
|
|
2645
|
+
import { all, retry } from "nanoeffects/effects";
|
|
1950
2646
|
|
|
1951
2647
|
const bus = createBus();
|
|
1952
|
-
const stopRequested = createAction("stop"); // an event with a name; calling it builds the action
|
|
1953
|
-
|
|
1954
|
-
bus.on("login", async (user, ctx) => {
|
|
1955
|
-
const reason = await take(ctx, "logout"); // resolves on the next logout
|
|
1956
|
-
cleanup(user, reason);
|
|
1957
|
-
|
|
1958
|
-
// an action creator works the same, and its payload type is the type of the result
|
|
1959
|
-
const stopped = await take(ctx, stopRequested);
|
|
1960
|
-
console.log(stopped);
|
|
1961
2648
|
|
|
1962
|
-
|
|
1963
|
-
|
|
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(
|
|
1964
2652
|
ctx,
|
|
1965
|
-
|
|
1966
|
-
|
|
2653
|
+
ids.map((id) => retry(() => loadUser(id), { times: 2 })),
|
|
2654
|
+
{ concurrency: 5 },
|
|
1967
2655
|
);
|
|
2656
|
+
|
|
2657
|
+
render(users);
|
|
1968
2658
|
});
|
|
1969
2659
|
```
|
|
1970
2660
|
|
|
1971
|
-
|
|
1972
|
-
- 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.
|
|
1973
|
-
- `options.predicate` skips payloads it rejects. In TypeScript, a type guard (`(payload): payload is Admin`) narrows the type of the result.
|
|
1974
|
-
- `options.signal` stops the wait. Inside `race`, `all` or `timeout` you do not need it: the group already stops the starter, so write `take("saved")` without options.
|
|
1975
|
-
- On abort of the task or of `options.signal`, unsubscribes and rejects with the abort reason.
|
|
1976
|
-
|
|
1977
|
-
## Run several starters together
|
|
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.
|
|
1978
2662
|
|
|
1979
|
-
|
|
2663
|
+
## `allSettled`
|
|
1980
2664
|
|
|
1981
|
-
|
|
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.
|
|
1982
2666
|
|
|
1983
2667
|
```js
|
|
1984
2668
|
import { createBus } from "nanoeffects";
|
|
@@ -1987,9 +2671,10 @@ import { allSettled } from "nanoeffects/effects";
|
|
|
1987
2671
|
const bus = createBus();
|
|
1988
2672
|
|
|
1989
2673
|
bus.on("open", async (id, ctx) => {
|
|
2674
|
+
// allSettled — wait for both, whatever the outcome
|
|
1990
2675
|
const { user, posts } = await allSettled(ctx, {
|
|
1991
|
-
user: (
|
|
1992
|
-
posts: (
|
|
2676
|
+
user: (child) => fetchUser(id, child),
|
|
2677
|
+
posts: (child) => fetchPosts(id, child),
|
|
1993
2678
|
});
|
|
1994
2679
|
|
|
1995
2680
|
if (user.status === "fulfilled") {
|
|
@@ -1998,7 +2683,9 @@ bus.on("open", async (id, ctx) => {
|
|
|
1998
2683
|
});
|
|
1999
2684
|
```
|
|
2000
2685
|
|
|
2001
|
-
`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)`.
|
|
2002
2689
|
|
|
2003
2690
|
```js
|
|
2004
2691
|
import { createBus } from "nanoeffects";
|
|
@@ -2007,9 +2694,9 @@ import { delay, race, take } from "nanoeffects/effects";
|
|
|
2007
2694
|
const bus = createBus();
|
|
2008
2695
|
|
|
2009
2696
|
bus.on("save", async (draft, ctx) => {
|
|
2010
|
-
// race — the first
|
|
2697
|
+
// race — the first to finish wins, the other is cancelled
|
|
2011
2698
|
const result = await race(ctx, {
|
|
2012
|
-
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
|
|
2013
2700
|
cancelled: take("cancel"), // take without ctx — the same, waits for the next "cancel"
|
|
2014
2701
|
});
|
|
2015
2702
|
|
|
@@ -2019,9 +2706,9 @@ bus.on("save", async (draft, ctx) => {
|
|
|
2019
2706
|
});
|
|
2020
2707
|
```
|
|
2021
2708
|
|
|
2022
|
-
**`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`).
|
|
2023
2710
|
|
|
2024
|
-
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:
|
|
2025
2712
|
|
|
2026
2713
|
```js
|
|
2027
2714
|
import { createBus } from "nanoeffects";
|
|
@@ -2031,7 +2718,7 @@ const bus = createBus();
|
|
|
2031
2718
|
|
|
2032
2719
|
bus.on("open", async (id, ctx) => {
|
|
2033
2720
|
const result = await race(ctx, {
|
|
2034
|
-
// retry without ctx —
|
|
2721
|
+
// retry without ctx — up to 3 attempts, each with the signal of the child
|
|
2035
2722
|
user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
|
|
2036
2723
|
// delay without ctx — the loser is cancelled when the other one finishes
|
|
2037
2724
|
waited: delay(5000),
|
|
@@ -2043,9 +2730,9 @@ bus.on("open", async (id, ctx) => {
|
|
|
2043
2730
|
});
|
|
2044
2731
|
```
|
|
2045
2732
|
|
|
2046
|
-
|
|
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.
|
|
2047
2734
|
|
|
2048
|
-
**`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:
|
|
2049
2736
|
|
|
2050
2737
|
```js
|
|
2051
2738
|
import { createBus } from "nanoeffects";
|
|
@@ -2055,7 +2742,7 @@ const bus = createBus();
|
|
|
2055
2742
|
|
|
2056
2743
|
bus.on("open", async (id, ctx) => {
|
|
2057
2744
|
const result = await race(ctx, {
|
|
2058
|
-
// all without ctx —
|
|
2745
|
+
// all without ctx — loads both, each with up to 3 attempts
|
|
2059
2746
|
loaded: all({
|
|
2060
2747
|
user: retry((_, { signal }) => fetchUser(id, { signal }), { times: 3 }),
|
|
2061
2748
|
posts: retry((_, { signal }) => fetchPosts(id, { signal }), { times: 3 }),
|
|
@@ -2070,46 +2757,77 @@ bus.on("open", async (id, ctx) => {
|
|
|
2070
2757
|
});
|
|
2071
2758
|
```
|
|
2072
2759
|
|
|
2073
|
-
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`
|
|
2074
2763
|
|
|
2075
|
-
|
|
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)).
|
|
2076
2765
|
|
|
2077
2766
|
```js
|
|
2078
2767
|
import { createBus } from "nanoeffects";
|
|
2079
|
-
import { all,
|
|
2768
|
+
import { all, delay, effectsPlugin, race } from "nanoeffects/effects";
|
|
2080
2769
|
|
|
2081
|
-
|
|
2770
|
+
// effectsPlugin — add race, all and delay to the context of every task
|
|
2771
|
+
const effects = effectsPlugin({ race, all, delay });
|
|
2082
2772
|
|
|
2083
|
-
bus
|
|
2084
|
-
|
|
2085
|
-
const users = await all(
|
|
2086
|
-
ctx,
|
|
2087
|
-
ids.map((id) => retry(() => loadUser(id), { times: 2 })),
|
|
2088
|
-
{ concurrency: 5 },
|
|
2089
|
-
);
|
|
2773
|
+
// use returns the same bus, typed with the new fields
|
|
2774
|
+
const bus = createBus().use(effects);
|
|
2090
2775
|
|
|
2091
|
-
|
|
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
|
+
});
|
|
2092
2790
|
});
|
|
2093
2791
|
```
|
|
2094
2792
|
|
|
2095
|
-
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
|
|
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.
|
|
2096
2802
|
|
|
2803
|
+
```js
|
|
2804
|
+
import { delay, retry } from "nanoeffects/effects";
|
|
2097
2805
|
|
|
2098
|
-
|
|
2806
|
+
const controller = new AbortController();
|
|
2099
2807
|
|
|
2100
|
-
|
|
2808
|
+
// outside a task there is no ctx: an object with a signal is enough
|
|
2809
|
+
const context = { signal: controller.signal };
|
|
2101
2810
|
|
|
2102
|
-
|
|
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
|
+
);
|
|
2103
2817
|
|
|
2104
|
-
|
|
2818
|
+
await delay(context, 500);
|
|
2105
2819
|
|
|
2106
|
-
|
|
2820
|
+
// stops the pending retries and waits
|
|
2821
|
+
controller.abort();
|
|
2822
|
+
```
|
|
2107
2823
|
|
|
2108
2824
|
---
|
|
2109
2825
|
Source: docs/errors.md
|
|
2110
2826
|
|
|
2111
2827
|
# Handle errors and unheard events
|
|
2112
2828
|
|
|
2829
|
+
> **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
|
|
2830
|
+
|
|
2113
2831
|
Defaults match a small bus. Set these when you want isolation or typo warnings.
|
|
2114
2832
|
|
|
2115
2833
|
## Isolate failing handlers
|
|
@@ -2131,9 +2849,9 @@ const bus = createBus({
|
|
|
2131
2849
|
|
|
2132
2850
|
## Warn when nobody listens
|
|
2133
2851
|
|
|
2134
|
-
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.
|
|
2135
2853
|
|
|
2136
|
-
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):
|
|
2137
2855
|
|
|
2138
2856
|
```js
|
|
2139
2857
|
import { createBus } from "nanoeffects";
|
|
@@ -2150,7 +2868,24 @@ Source: docs/actions.md
|
|
|
2150
2868
|
|
|
2151
2869
|
# Actions, jobs and modules
|
|
2152
2870
|
|
|
2153
|
-
|
|
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.
|
|
2154
2889
|
|
|
2155
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.
|
|
2156
2891
|
|
|
@@ -2160,11 +2895,37 @@ A chat with two entities and three features, jobs under policies and effects, an
|
|
|
2160
2895
|
|
|
2161
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)).
|
|
2162
2897
|
|
|
2163
|
-
##
|
|
2898
|
+
## Action, job and module
|
|
2899
|
+
|
|
2900
|
+
Four forms, one idea each. Take the first row that fits.
|
|
2901
|
+
|
|
2902
|
+
| Form | What it is | Use it when |
|
|
2903
|
+
| ---- | ---------- | ----------- |
|
|
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. |
|
|
2908
|
+
|
|
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.
|
|
2164
2910
|
|
|
2165
|
-
|
|
2911
|
+
## On this page
|
|
2912
|
+
|
|
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)
|
|
2916
|
+
- [Actions](#actions)
|
|
2917
|
+
- [Bind a handler later](#bind-a-handler-later)
|
|
2918
|
+
- [The result type of a bound handler](#the-result-type-of-a-bound-handler)
|
|
2919
|
+
- [Jobs](#jobs)
|
|
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)
|
|
2923
|
+
|
|
2924
|
+
## Get the result of `emit`
|
|
2925
|
+
|
|
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.
|
|
2166
2927
|
|
|
2167
|
-
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.
|
|
2168
2929
|
|
|
2169
2930
|
```js
|
|
2170
2931
|
// bus.js
|
|
@@ -2189,7 +2950,7 @@ import { double } from "./math";
|
|
|
2189
2950
|
bus.emit(double(21)); // 42
|
|
2190
2951
|
```
|
|
2191
2952
|
|
|
2192
|
-
##
|
|
2953
|
+
## Actions, handlers and jobs
|
|
2193
2954
|
|
|
2194
2955
|
`nanoeffects/actions` has the pieces to name what a bus does, apart from the code that does it.
|
|
2195
2956
|
|
|
@@ -2224,7 +2985,7 @@ These are the events of the user domain, in a file of their own (`user.js`). The
|
|
|
2224
2985
|
- `userLoaded.match(action)` — narrows an action
|
|
2225
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
|
|
2226
2987
|
- `createAction(type, { prepare })` — build the payload from several arguments
|
|
2227
|
-
- `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)
|
|
2228
2989
|
- Works as the event of its type: `bus.emit(userLoaded(user))`, `bus.on(userLoaded, fn)`
|
|
2229
2990
|
|
|
2230
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 })`.
|
|
@@ -2274,7 +3035,7 @@ A handler bound in the registry makes `bus.emit(sendMail(mail))` run it and retu
|
|
|
2274
3035
|
|
|
2275
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.
|
|
2276
3037
|
|
|
2277
|
-
###
|
|
3038
|
+
### The result type of a bound handler
|
|
2278
3039
|
|
|
2279
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.
|
|
2280
3041
|
|
|
@@ -2314,11 +3075,11 @@ On a bus without an event map you can say it where you call: `const id = bus.emi
|
|
|
2314
3075
|
- `pending` / `success` / `fail` / `cancel` are a string (job makes an action creator) or an existing action creator. Missing ones become `<type>/pending`, `<type>/success`, `<type>/fail` or `<type>/cancel`.
|
|
2315
3076
|
- `bus.emit(loadUser(id))` returns a promise of the result. Handler errors are always handled: they go to `fail` and the promise resolves with `undefined`. For a result that must reject, give `createAction` the handler.
|
|
2316
3077
|
- Outcome actions are emitted as the task that ran the job (also for a handler bound with `actions.handle`). `success` and `fail` are not sent after abort; `cancel` is, at the moment of the abort.
|
|
2317
|
-
- A job without a handler is not an outcome: `emit` throws `No handler for
|
|
3078
|
+
- A job without a handler is not an outcome: `emit` throws `No handler for action "..."` and nothing is sent. An action without a handler is an event and does not throw.
|
|
2318
3079
|
- Calling the job only builds `{ type, payload }`; the handler runs on `emit`.
|
|
2319
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.
|
|
2320
3081
|
|
|
2321
|
-
##
|
|
3082
|
+
## The state of a job
|
|
2322
3083
|
|
|
2323
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`.
|
|
2324
3085
|
|
|
@@ -2391,7 +3152,7 @@ bus.emit(loadUser("1"));
|
|
|
2391
3152
|
|
|
2392
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.
|
|
2393
3154
|
|
|
2394
|
-
##
|
|
3155
|
+
## Modules
|
|
2395
3156
|
|
|
2396
3157
|
**Problem.** A domain needs several subscriptions and handler bindings. With plain `bus.on` / `actions.handle` you must remember every unsubscribe.
|
|
2397
3158
|
|
|
@@ -2484,9 +3245,9 @@ If the `setup` of one module throws, the modules of that call that were already
|
|
|
2484
3245
|
- `spawn` — background task, aborted on unmount.
|
|
2485
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.
|
|
2486
3247
|
|
|
2487
|
-
##
|
|
3248
|
+
## Extend the context of actions and modules
|
|
2488
3249
|
|
|
2489
|
-
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`:
|
|
2490
3251
|
|
|
2491
3252
|
```js
|
|
2492
3253
|
// bus.js
|
|
@@ -2518,13 +3279,15 @@ const fakeApi = { getUser: async (id) => ({ id, name: "test" }) };
|
|
|
2518
3279
|
bus.emit(getUser("1"), { api: fakeApi });
|
|
2519
3280
|
```
|
|
2520
3281
|
|
|
2521
|
-
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).
|
|
2522
3283
|
|
|
2523
3284
|
---
|
|
2524
3285
|
Source: docs/stores.md
|
|
2525
3286
|
|
|
2526
3287
|
# Connect Redux, Zustand, MobX, or Pinia
|
|
2527
3288
|
|
|
3289
|
+
> **Level E.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
3290
|
+
|
|
2528
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).
|
|
2529
3292
|
|
|
2530
3293
|
If you do not have a store yet, use `createBus` from the [Guide](guide.md).
|
|
@@ -3198,11 +3961,26 @@ A handler gets the store from `ctx.store` and calls a mutation, whatever it hand
|
|
|
3198
3961
|
---
|
|
3199
3962
|
Source: docs/query-libraries.md
|
|
3200
3963
|
|
|
3201
|
-
# Use nanoeffects with React Query and
|
|
3964
|
+
# Use nanoeffects with React Query, SWR and RTK Query
|
|
3965
|
+
|
|
3966
|
+
> **Level E.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
3967
|
+
|
|
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.
|
|
3969
|
+
|
|
3970
|
+
You need a React project, `nanoeffects`, and one of the libraries. `api` below is your own client: replace it with your requests.
|
|
3202
3971
|
|
|
3203
|
-
|
|
3972
|
+
## On this page
|
|
3204
3973
|
|
|
3205
|
-
|
|
3974
|
+
- [Decide who owns what](#decide-who-owns-what)
|
|
3975
|
+
- [Make the action](#make-the-action)
|
|
3976
|
+
- [Call it from React Query](#call-it-from-react-query)
|
|
3977
|
+
- [Call it from SWR](#call-it-from-swr)
|
|
3978
|
+
- [Let the bus know the result](#let-the-bus-know-the-result)
|
|
3979
|
+
- [Call it from RTK Query](#call-it-from-rtk-query)
|
|
3980
|
+
- [Give the client its signal once](#give-the-client-its-signal-once)
|
|
3981
|
+
- [Push a live stream into the cache](#push-a-live-stream-into-the-cache)
|
|
3982
|
+
- [Refetch queries when the bus says so](#refetch-queries-when-the-bus-says-so)
|
|
3983
|
+
- [Know the limits](#know-the-limits)
|
|
3206
3984
|
|
|
3207
3985
|
## Decide who owns what
|
|
3208
3986
|
|
|
@@ -3263,7 +4041,7 @@ import { getUser } from "./user";
|
|
|
3263
4041
|
export function Profile({ id }) {
|
|
3264
4042
|
const { isLoading, data, error } = useQuery({
|
|
3265
4043
|
queryKey: ["user", id],
|
|
3266
|
-
// emit — runs the
|
|
4044
|
+
// emit — runs the action; { signal } ties its task to the signal of React Query
|
|
3267
4045
|
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
3268
4046
|
});
|
|
3269
4047
|
|
|
@@ -3313,11 +4091,266 @@ import { createAction } from "nanoeffects/actions";
|
|
|
3313
4091
|
export const userLoaded = createAction("user/loaded");
|
|
3314
4092
|
```
|
|
3315
4093
|
|
|
3316
|
-
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).
|
|
4095
|
+
|
|
4096
|
+
## Call it from RTK Query
|
|
4097
|
+
|
|
4098
|
+
<!-- docs-check: new -->
|
|
4099
|
+
|
|
4100
|
+
RTK Query gives the `queryFn` of an endpoint a `signal` in its second argument, so the call is the same as with React Query: pass it to `emit`. This page does not set up the store; see [Stores](stores.md) for that.
|
|
4101
|
+
|
|
4102
|
+
```js
|
|
4103
|
+
// bus.js
|
|
4104
|
+
import { createBus } from "nanoeffects";
|
|
4105
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
4106
|
+
|
|
4107
|
+
// signalPlugin — lets emit take { signal }
|
|
4108
|
+
export const bus = createBus().use(signalPlugin());
|
|
4109
|
+
```
|
|
4110
|
+
|
|
4111
|
+
```js
|
|
4112
|
+
// user.js
|
|
4113
|
+
import { createAction } from "nanoeffects/actions";
|
|
4114
|
+
|
|
4115
|
+
// createAction with a handler — emit returns what the handler returns
|
|
4116
|
+
export const getUser = createAction("user/get", (id, { signal }) =>
|
|
4117
|
+
api.getUser(id, { signal }),
|
|
4118
|
+
);
|
|
4119
|
+
```
|
|
4120
|
+
|
|
4121
|
+
```js
|
|
4122
|
+
// api-slice.js
|
|
4123
|
+
import { createApi, fakeBaseQuery } from "@reduxjs/toolkit/query/react";
|
|
4124
|
+
import { bus } from "./bus";
|
|
4125
|
+
import { getUser } from "./user";
|
|
4126
|
+
|
|
4127
|
+
export const userApi = createApi({
|
|
4128
|
+
reducerPath: "userApi",
|
|
4129
|
+
baseQuery: fakeBaseQuery(), // RTK Query — every endpoint brings its own queryFn
|
|
4130
|
+
endpoints: (build) => ({
|
|
4131
|
+
getUser: build.query({
|
|
4132
|
+
// emit — runs the action; { signal } ties its task to the request of RTK Query
|
|
4133
|
+
queryFn: async (id, { signal }) => {
|
|
4134
|
+
const user = await bus.emit(getUser(id), { signal });
|
|
4135
|
+
return { data: user };
|
|
4136
|
+
},
|
|
4137
|
+
}),
|
|
4138
|
+
}),
|
|
4139
|
+
});
|
|
4140
|
+
```
|
|
4141
|
+
|
|
4142
|
+
## Give the client its signal once
|
|
4143
|
+
|
|
4144
|
+
<!-- docs-check: new -->
|
|
4145
|
+
|
|
4146
|
+
Problem: the action passes `signal` to every call of `api`, and one forgotten `{ signal }` leaves a request running after the query is gone. `fromSignal` builds the client from the signal of each task, so the handler cannot forget it. Use it when many actions share one client. A single action is simpler as it is in [Make the action](#make-the-action).
|
|
4147
|
+
|
|
4148
|
+
`fromSignal` marks a field of `contextPlugin`. The client is made when a handler first reads `ctx.http`, from the `ctx.signal` of that task. More in [Context fields](context.md#build-a-field-from-the-signal-of-each-task).
|
|
4149
|
+
|
|
4150
|
+
```js
|
|
4151
|
+
// bus.js
|
|
4152
|
+
import axios from "axios";
|
|
4153
|
+
import { createBus } from "nanoeffects";
|
|
4154
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
4155
|
+
import { fromSignal, signalPlugin } from "nanoeffects/signal";
|
|
4156
|
+
|
|
4157
|
+
// fromSignal — http is a new axios client for each task, bound to the signal of that task
|
|
4158
|
+
const http = fromSignal((signal) => axios.create({ baseURL: "/api", signal }));
|
|
4159
|
+
|
|
4160
|
+
// contextPlugin — every handler gets ctx.http
|
|
4161
|
+
const context = contextPlugin({ http });
|
|
4162
|
+
|
|
4163
|
+
// signalPlugin — emit takes { signal }, so the signal of the query reaches the task
|
|
4164
|
+
export const bus = createBus().use(signalPlugin()).use(context);
|
|
4165
|
+
```
|
|
4166
|
+
|
|
4167
|
+
```js
|
|
4168
|
+
// user.js
|
|
4169
|
+
import { createAction } from "nanoeffects/actions";
|
|
4170
|
+
|
|
4171
|
+
// the handler never sees a signal: ctx.http stops when the task aborts
|
|
4172
|
+
export const getUser = createAction("user/get", async (id, { http }) => {
|
|
4173
|
+
const response = await http.get(`/users/${id}`);
|
|
4174
|
+
return response.data;
|
|
4175
|
+
});
|
|
4176
|
+
```
|
|
4177
|
+
|
|
4178
|
+
```jsx
|
|
4179
|
+
// Profile.jsx
|
|
4180
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4181
|
+
import { bus } from "./bus";
|
|
4182
|
+
import { getUser } from "./user";
|
|
4183
|
+
|
|
4184
|
+
export function Profile({ id }) {
|
|
4185
|
+
const { data } = useQuery({
|
|
4186
|
+
queryKey: ["user", id],
|
|
4187
|
+
// emit — the signal of React Query aborts the task, and with it the request of ctx.http
|
|
4188
|
+
queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
|
|
4189
|
+
});
|
|
4190
|
+
|
|
4191
|
+
return <p>{data?.name}</p>;
|
|
4192
|
+
}
|
|
4193
|
+
```
|
|
4194
|
+
|
|
4195
|
+
## Push a live stream into the cache
|
|
4196
|
+
|
|
4197
|
+
<!-- docs-check: new -->
|
|
4198
|
+
|
|
4199
|
+
Problem: prices arrive from a socket, and the component should show the newest one without a refetch. The socket code emits an event on the bus; the component reads those events with `events` and writes each into the cache. Both libraries have an entry point for pushed data: `setQueryData` in React Query and `useSWRSubscription` in SWR.
|
|
4200
|
+
|
|
4201
|
+
`events` gives the payloads to a `for await` loop. `buffer: 1` keeps only the newest price while a write is running. The abort in the cleanup ends the loop and removes the listener. More in [Read an event with a `for await` loop](observable.md#read-an-event-with-a-for-await-loop).
|
|
4202
|
+
|
|
4203
|
+
```js
|
|
4204
|
+
// bus.js
|
|
4205
|
+
import { createBus } from "nanoeffects";
|
|
4206
|
+
|
|
4207
|
+
// createBus — the bus the socket code and the components share
|
|
4208
|
+
export const bus = createBus();
|
|
4209
|
+
```
|
|
4210
|
+
|
|
4211
|
+
```js
|
|
4212
|
+
// prices.js
|
|
4213
|
+
import { createAction } from "nanoeffects/actions";
|
|
4214
|
+
|
|
4215
|
+
// createAction — the event the socket code emits for every new price
|
|
4216
|
+
export const priceChanged = createAction("price/changed");
|
|
4217
|
+
```
|
|
4218
|
+
|
|
4219
|
+
```jsx
|
|
4220
|
+
// Price.jsx
|
|
4221
|
+
import { useEffect } from "react";
|
|
4222
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4223
|
+
import { events } from "nanoeffects/observable";
|
|
4224
|
+
import { bus } from "./bus";
|
|
4225
|
+
import { priceChanged } from "./prices";
|
|
4226
|
+
|
|
4227
|
+
export function Price({ symbol }) {
|
|
4228
|
+
const queryClient = useQueryClient();
|
|
4229
|
+
const { data } = useQuery({
|
|
4230
|
+
queryKey: ["price", symbol],
|
|
4231
|
+
queryFn: () => api.getPrice(symbol), // the first value; the stream keeps it fresh
|
|
4232
|
+
});
|
|
4233
|
+
|
|
4234
|
+
useEffect(() => {
|
|
4235
|
+
const controller = new AbortController();
|
|
4236
|
+
// events — the newest price event, one at a time
|
|
4237
|
+
const prices = events(bus, priceChanged, {
|
|
4238
|
+
signal: controller.signal,
|
|
4239
|
+
buffer: 1,
|
|
4240
|
+
});
|
|
4241
|
+
|
|
4242
|
+
async function follow() {
|
|
4243
|
+
for await (const price of prices) {
|
|
4244
|
+
if (price.symbol === symbol) {
|
|
4245
|
+
queryClient.setQueryData(["price", symbol], price.value); // write into the cache
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
follow().catch((error) => {
|
|
4251
|
+
if (!controller.signal.aborted) throw error; // the abort ends the loop; anything else is a bug
|
|
4252
|
+
});
|
|
4253
|
+
|
|
4254
|
+
return () => controller.abort(); // the component left: the loop ends, the listener goes
|
|
4255
|
+
}, [queryClient, symbol]);
|
|
4256
|
+
|
|
4257
|
+
return <p>{data}</p>;
|
|
4258
|
+
}
|
|
4259
|
+
```
|
|
4260
|
+
|
|
4261
|
+
SWR does the same with `useSWRSubscription` from `swr/subscription`. Its subscribe function gets `next` to push a value and returns the cleanup:
|
|
4262
|
+
|
|
4263
|
+
```jsx
|
|
4264
|
+
// Price.jsx
|
|
4265
|
+
import useSWRSubscription from "swr/subscription";
|
|
4266
|
+
import { events } from "nanoeffects/observable";
|
|
4267
|
+
import { bus } from "./bus";
|
|
4268
|
+
import { priceChanged } from "./prices";
|
|
4269
|
+
|
|
4270
|
+
export function Price({ symbol }) {
|
|
4271
|
+
const { data } = useSWRSubscription(["price", symbol], (_key, { next }) => {
|
|
4272
|
+
const controller = new AbortController();
|
|
4273
|
+
// events — the newest price event, one at a time
|
|
4274
|
+
const prices = events(bus, priceChanged, {
|
|
4275
|
+
signal: controller.signal,
|
|
4276
|
+
buffer: 1,
|
|
4277
|
+
});
|
|
4278
|
+
|
|
4279
|
+
async function follow() {
|
|
4280
|
+
for await (const price of prices) {
|
|
4281
|
+
if (price.symbol === symbol) {
|
|
4282
|
+
next(null, price.value); // SWR — push the value to the hook
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
follow().catch((error) => {
|
|
4288
|
+
if (!controller.signal.aborted) next(error);
|
|
4289
|
+
});
|
|
4290
|
+
|
|
4291
|
+
return () => controller.abort(); // SWR calls this when the last component of the key leaves
|
|
4292
|
+
});
|
|
4293
|
+
|
|
4294
|
+
return <p>{data}</p>;
|
|
4295
|
+
}
|
|
4296
|
+
```
|
|
4297
|
+
|
|
4298
|
+
## Refetch queries when the bus says so
|
|
4299
|
+
|
|
4300
|
+
<!-- docs-check: new -->
|
|
4301
|
+
|
|
4302
|
+
Problem: a save in one part of the app should refresh the lists in another. The save emits an event, and the component that owns the queries listens for it. One `AbortController` ends every listener of the component, instead of one `unbind` for each. For a single listener, `return bus.on(…)` from the effect is shorter.
|
|
4303
|
+
|
|
4304
|
+
This needs `signalPlugin`, because `{ signal }` in the options of `on` is read by the plugin. Without it the listeners stay after the component leaves.
|
|
4305
|
+
|
|
4306
|
+
```js
|
|
4307
|
+
// bus.js
|
|
4308
|
+
import { createBus } from "nanoeffects";
|
|
4309
|
+
import { signalPlugin } from "nanoeffects/signal";
|
|
4310
|
+
|
|
4311
|
+
// signalPlugin — on takes { signal } and removes the listener when it aborts
|
|
4312
|
+
export const bus = createBus().use(signalPlugin());
|
|
4313
|
+
```
|
|
4314
|
+
|
|
4315
|
+
```js
|
|
4316
|
+
// user-events.js
|
|
4317
|
+
import { createAction } from "nanoeffects/actions";
|
|
4318
|
+
|
|
4319
|
+
// createAction — events the save code emits
|
|
4320
|
+
export const userSaved = createAction("user/saved");
|
|
4321
|
+
export const userDeleted = createAction("user/deleted");
|
|
4322
|
+
```
|
|
4323
|
+
|
|
4324
|
+
```jsx
|
|
4325
|
+
// UserList.jsx
|
|
4326
|
+
import { useEffect } from "react";
|
|
4327
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4328
|
+
import { bus } from "./bus";
|
|
4329
|
+
import { userDeleted, userSaved } from "./user-events";
|
|
4330
|
+
|
|
4331
|
+
export function UserList() {
|
|
4332
|
+
const queryClient = useQueryClient();
|
|
4333
|
+
const { data } = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() });
|
|
4334
|
+
|
|
4335
|
+
useEffect(() => {
|
|
4336
|
+
const controller = new AbortController();
|
|
4337
|
+
const { signal } = controller;
|
|
4338
|
+
const refresh = () => queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
4339
|
+
|
|
4340
|
+
// on with { signal } — both listeners go when the component leaves
|
|
4341
|
+
bus.on(userSaved, refresh, { signal });
|
|
4342
|
+
bus.on(userDeleted, refresh, { signal });
|
|
4343
|
+
|
|
4344
|
+
return () => controller.abort();
|
|
4345
|
+
}, [queryClient]);
|
|
4346
|
+
|
|
4347
|
+
return <ul>{data?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
|
|
4348
|
+
}
|
|
4349
|
+
```
|
|
3317
4350
|
|
|
3318
4351
|
## Know the limits
|
|
3319
4352
|
|
|
3320
|
-
- **Without the plugin, `{ signal }` is ignored
|
|
4353
|
+
- **Without the plugin, `{ signal }` is ignored,** in `emit` and in the options of `on`. The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
|
|
3321
4354
|
- **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
|
|
3322
4355
|
- **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Actions that fetch are async, so this only matters for a sync handler that sets up a callback.
|
|
3323
4356
|
- **Give each request its own signal for an event.** For an action with a handler or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
|
|
@@ -3330,6 +4363,8 @@ Source: docs/server.md
|
|
|
3330
4363
|
|
|
3331
4364
|
# Stop work on a server when the client leaves
|
|
3332
4365
|
|
|
4366
|
+
> **Level C.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
4367
|
+
|
|
3333
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.
|
|
3334
4369
|
|
|
3335
4370
|
You need Node 18 or newer, `fastify` and `nanoeffects`. `api` below is your own client for another service: replace it with your requests.
|
|
@@ -3367,7 +4402,7 @@ The action loads a user. It tries the request up to three times, and gives up on
|
|
|
3367
4402
|
import { createAction } from "nanoeffects/actions";
|
|
3368
4403
|
import { retry, timeout } from "nanoeffects/effects";
|
|
3369
4404
|
|
|
3370
|
-
// createAction —
|
|
4405
|
+
// createAction — an action with a handler: emit returns what the handler returns
|
|
3371
4406
|
export const getUser = createAction("user/get", async (id, ctx) => {
|
|
3372
4407
|
// one try: the request, with the signal of timeout so it can stop it
|
|
3373
4408
|
const load = ({ signal }) => api.getUser(id, { signal });
|
|
@@ -3408,7 +4443,7 @@ app.get("/users/:id", async (request, reply) => {
|
|
|
3408
4443
|
});
|
|
3409
4444
|
|
|
3410
4445
|
try {
|
|
3411
|
-
// emit with { signal } — aborting the controller aborts the
|
|
4446
|
+
// emit with { signal } — aborting the controller aborts the action: timeout, retry and the request
|
|
3412
4447
|
const call = getUser(request.params.id);
|
|
3413
4448
|
return await bus.emit(call, { signal: controller.signal });
|
|
3414
4449
|
} catch (error) {
|
|
@@ -3461,7 +4496,7 @@ import { createAction } from "nanoeffects/actions";
|
|
|
3461
4496
|
import { poll } from "nanoeffects/effects";
|
|
3462
4497
|
import { latest } from "nanoeffects/policies";
|
|
3463
4498
|
|
|
3464
|
-
// createAction —
|
|
4499
|
+
// createAction — an action with a handler
|
|
3465
4500
|
// latest — a new subscription stops the old one; key — but only the old one of the same socket
|
|
3466
4501
|
export const watchPrices = createAction(
|
|
3467
4502
|
"prices/watch",
|
|
@@ -3536,21 +4571,35 @@ Source: docs/react.md
|
|
|
3536
4571
|
|
|
3537
4572
|
# Build a sign-up form in React
|
|
3538
4573
|
|
|
3539
|
-
|
|
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.
|
|
3540
4579
|
|
|
3541
4580
|
You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
|
|
3542
4581
|
|
|
3543
|
-
##
|
|
4582
|
+
## On this page
|
|
4583
|
+
|
|
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)
|
|
4588
|
+
- [Show the last value of an event as state](#show-the-last-value-of-an-event-as-state)
|
|
4589
|
+
- [Keep a debounced handler between renders](#keep-a-debounced-handler-between-renders)
|
|
4590
|
+
- [Where to go next](#where-to-go-next)
|
|
4591
|
+
|
|
4592
|
+
## Problems of the form without a bus
|
|
3544
4593
|
|
|
3545
4594
|
A registration form has three problems that `useEffect` and `fetch` leave to you:
|
|
3546
4595
|
|
|
3547
|
-
- **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.
|
|
3548
4597
|
- **A double submit.** A second click on "Sign up" sends a second request while the first one is running.
|
|
3549
4598
|
- **A flaky network.** One failed request should be retried, but a "name is taken" answer should not.
|
|
3550
4599
|
|
|
3551
4600
|
Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one place, outside the component.
|
|
3552
4601
|
|
|
3553
|
-
##
|
|
4602
|
+
## 1. The bus and the handlers
|
|
3554
4603
|
|
|
3555
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.
|
|
3556
4605
|
|
|
@@ -3607,11 +4656,11 @@ bus.on(
|
|
|
3607
4656
|
|
|
3608
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`.
|
|
3609
4658
|
|
|
3610
|
-
##
|
|
4659
|
+
## 2. The form component
|
|
3611
4660
|
|
|
3612
4661
|
<!-- docs-check: continue -->
|
|
3613
4662
|
|
|
3614
|
-
The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount.
|
|
4663
|
+
The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount. If the component already holds an `AbortSignal`, `bus.on(event, handler, { signal })` ends the listener with it; see [`signalPlugin`](plugins.md#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin).
|
|
3615
4664
|
|
|
3616
4665
|
```jsx
|
|
3617
4666
|
// SignUpForm.jsx
|
|
@@ -3678,11 +4727,11 @@ The `disabled` button is only for the look. The form stays correct without it: `
|
|
|
3678
4727
|
|
|
3679
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.
|
|
3680
4729
|
|
|
3681
|
-
## Stop
|
|
4730
|
+
## 3. Stop requests when the form closes
|
|
3682
4731
|
|
|
3683
4732
|
<!-- docs-check: continue -->
|
|
3684
4733
|
|
|
3685
|
-
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`:
|
|
3686
4735
|
|
|
3687
4736
|
```js
|
|
3688
4737
|
import { latest } from "nanoeffects/policies";
|
|
@@ -3799,26 +4848,53 @@ export const Search = ({ wait }) => {
|
|
|
3799
4848
|
};
|
|
3800
4849
|
```
|
|
3801
4850
|
|
|
3802
|
-
- `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
|
|
3803
|
-
- A pending call is cancelled on unmount and when `deps` change.
|
|
3804
|
-
- 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.
|
|
3805
|
-
- 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.
|
|
3806
|
-
- `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.
|
|
3807
|
-
- Without a React component, use [`withPolicy`](policies.md#
|
|
4851
|
+
- `onChange` keeps its identity while `wait` stays the same, so it is safe in `useEffect` and in props.
|
|
4852
|
+
- A pending call is cancelled on unmount and when `deps` change.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
3808
4882
|
|
|
3809
4883
|
## Where to go next
|
|
3810
4884
|
|
|
3811
4885
|
- The policies behind this form: [Policies](policies.md).
|
|
3812
|
-
- 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).
|
|
3813
4887
|
- `retry`, `timeout` and the other effects: [Effects](effects.md).
|
|
3814
4888
|
- A state library instead of `useState`: [Stores](stores.md).
|
|
3815
|
-
- React Query or SWR instead of your own state: [Use nanoeffects with React Query and
|
|
4889
|
+
- React Query or SWR instead of your own state: [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
|
|
3816
4890
|
|
|
3817
4891
|
---
|
|
3818
4892
|
Source: docs/chat-example.md
|
|
3819
4893
|
|
|
3820
4894
|
# Build a chat with entities, features, modules and a small server
|
|
3821
4895
|
|
|
4896
|
+
> **Level D.** Read first: [Actions, jobs and modules](actions.md).
|
|
4897
|
+
|
|
3822
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.
|
|
3823
4899
|
|
|
3824
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.
|
|
@@ -3833,6 +4909,18 @@ npm run dev
|
|
|
3833
4909
|
|
|
3834
4910
|
`npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
|
|
3835
4911
|
|
|
4912
|
+
## On this page
|
|
4913
|
+
|
|
4914
|
+
- [Read how the server fails](#read-how-the-server-fails)
|
|
4915
|
+
- [See how the files are split](#see-how-the-files-are-split)
|
|
4916
|
+
- [Load a history that must not race](#load-a-history-that-must-not-race)
|
|
4917
|
+
- [Send a message that can fail](#send-a-message-that-can-fail)
|
|
4918
|
+
- [Search where one room is slow](#search-where-one-room-is-slow)
|
|
4919
|
+
- [Wait for a room before marking a result](#wait-for-a-room-before-marking-a-result)
|
|
4920
|
+
- [Poll for new messages](#poll-for-new-messages)
|
|
4921
|
+
- [Know where each value lives](#know-where-each-value-lives)
|
|
4922
|
+
- [Where to go next](#where-to-go-next)
|
|
4923
|
+
|
|
3836
4924
|
## Read how the server fails
|
|
3837
4925
|
|
|
3838
4926
|
The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
|
|
@@ -3895,7 +4983,7 @@ import { latest } from "nanoeffects/policies";
|
|
|
3895
4983
|
export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
|
|
3896
4984
|
```
|
|
3897
4985
|
|
|
3898
|
-
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)):
|
|
3899
4987
|
|
|
3900
4988
|
```js
|
|
3901
4989
|
// message-module.js
|
|
@@ -4075,6 +5163,8 @@ Source: docs/chat-widget.md
|
|
|
4075
5163
|
|
|
4076
5164
|
# Build a chat widget that listens only while it is open
|
|
4077
5165
|
|
|
5166
|
+
> **Level D.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
5167
|
+
|
|
4078
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.
|
|
4079
5169
|
|
|
4080
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:
|
|
@@ -4105,7 +5195,7 @@ It is a level D example: the logic lives in one module, and the page only mounts
|
|
|
4105
5195
|
| `src/chat-handlers.ts` | `askBot` (`timeout` and `retry` inside) and `watchOperator` (`poll`): plain `async` functions that know nothing about the page. |
|
|
4106
5196
|
| `src/chat-module.ts` | The list of what is alive while the chat is open. |
|
|
4107
5197
|
| `src/panel.ts` | The view: shows the lines, sends the texts, returns one function that removes it. |
|
|
4108
|
-
| `src/main.ts` | The launcher: the only file that knows both the module and the panel. |
|
|
5198
|
+
| `src/main.ts` | The launcher: the only file that knows both the module and the panel; it listens for `closeRequested` only while the chat is open. |
|
|
4109
5199
|
|
|
4110
5200
|
## Bind everything in one module
|
|
4111
5201
|
|
|
@@ -4155,7 +5245,7 @@ Three details that make the cleanup complete:
|
|
|
4155
5245
|
|
|
4156
5246
|
## Open and close from the launcher
|
|
4157
5247
|
|
|
4158
|
-
`app.mount` returns the function that unmounts the module. The launcher puts
|
|
5248
|
+
`app.mount` returns the function that unmounts the module. `bus.on` returns the one that unsubscribes. The launcher puts them together with the one that removes the panel into a single `close`.
|
|
4159
5249
|
|
|
4160
5250
|
<!-- docs-check: skip - the other files of the example are in examples/chat-widget -->
|
|
4161
5251
|
```js
|
|
@@ -4171,7 +5261,10 @@ let close = null; // set while the chat is open
|
|
|
4171
5261
|
const open = () => {
|
|
4172
5262
|
const stopModule = app.mount(chatModule); // subscriptions, polling and Escape start
|
|
4173
5263
|
const stopPanel = renderPanel(document.body);
|
|
5264
|
+
// the module emits closeRequested on Escape; nothing does while the chat is closed
|
|
5265
|
+
const stopEscape = bus.on(closeRequested, () => close?.());
|
|
4174
5266
|
close = () => {
|
|
5267
|
+
stopEscape();
|
|
4175
5268
|
stopPanel();
|
|
4176
5269
|
stopModule(); // everything the module started ends, a running request included
|
|
4177
5270
|
close = null;
|
|
@@ -4185,18 +5278,15 @@ button.addEventListener("click", () => {
|
|
|
4185
5278
|
open();
|
|
4186
5279
|
}
|
|
4187
5280
|
});
|
|
4188
|
-
|
|
4189
|
-
// the launcher stays subscribed: it must hear Escape to close the chat
|
|
4190
|
-
bus.on(closeRequested, () => close?.());
|
|
4191
5281
|
```
|
|
4192
5282
|
|
|
4193
|
-
|
|
5283
|
+
While the chat is closed the launcher holds no subscription at all: it only has the click listener of the button, which is a DOM listener, not a listener of the bus.
|
|
4194
5284
|
|
|
4195
5285
|
## Check that closing cleans up
|
|
4196
5286
|
|
|
4197
5287
|
1. Open the chat and send a text. The bot answers after about a second. Every third question fails, and the retry asks again.
|
|
4198
5288
|
2. Send a text and close the chat before the answer arrives. No line appears, also when you open the chat again.
|
|
4199
|
-
3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed:
|
|
5289
|
+
3. Look at the box "Listeners on the bus": it shows `bus.get(event).length` for four events and updates when the chat opens or closes. Closed: every event shows `0`, and so does "all events". Open: each of the four has one listener, and "all events" is `4`. That is the proof that nothing is left subscribed.
|
|
4200
5290
|
4. Wait 10 s with the chat closed. The operator is not polled: no line from the operator appears.
|
|
4201
5291
|
5. Press `Escape` with the chat closed. Nothing happens. Open the chat and press it again: it closes.
|
|
4202
5292
|
6. Open the chat again. The old lines are back, and the polling runs.
|
|
@@ -4209,11 +5299,77 @@ The launcher's own `bus.on` is the only subscription that lives while the chat i
|
|
|
4209
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`).
|
|
4210
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.
|
|
4211
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
|
+
|
|
4212
5366
|
---
|
|
4213
5367
|
Source: docs/vue.md
|
|
4214
5368
|
|
|
4215
5369
|
# Use nanoeffects in a Vue app
|
|
4216
5370
|
|
|
5371
|
+
> **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
5372
|
+
|
|
4217
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).
|
|
4218
5374
|
|
|
4219
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.
|
|
@@ -4306,7 +5462,7 @@ In TypeScript the type of the `ref` comes from the event: with `createAction<str
|
|
|
4306
5462
|
## Where to go next
|
|
4307
5463
|
|
|
4308
5464
|
- The same two jobs for React: [Build a sign-up form in React](react.md).
|
|
4309
|
-
- 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.
|
|
4310
5466
|
- Keep values in Pinia: [Connect Redux, Zustand, MobX, or Pinia](stores.md).
|
|
4311
5467
|
|
|
4312
5468
|
---
|
|
@@ -4314,6 +5470,8 @@ Source: docs/svelte.md
|
|
|
4314
5470
|
|
|
4315
5471
|
# Use nanoeffects in a Svelte app
|
|
4316
5472
|
|
|
5473
|
+
> **Level B.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
5474
|
+
|
|
4317
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).
|
|
4318
5476
|
|
|
4319
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.
|
|
@@ -4392,16 +5550,39 @@ bus.on(searchLoaded, (titles) => foundTitles.set(titles));
|
|
|
4392
5550
|
## Where to go next
|
|
4393
5551
|
|
|
4394
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).
|
|
4395
|
-
- 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.
|
|
4396
5554
|
|
|
4397
5555
|
---
|
|
4398
5556
|
Source: docs/context.md
|
|
4399
5557
|
|
|
4400
|
-
#
|
|
5558
|
+
# Working with context
|
|
5559
|
+
|
|
5560
|
+
> **Level D.** Read first: [Guide](guide.md#1-create-a-bus).
|
|
4401
5561
|
|
|
4402
|
-
Optional: put a store, client
|
|
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.
|
|
4403
5563
|
|
|
4404
|
-
##
|
|
5564
|
+
## On this page
|
|
5565
|
+
|
|
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
|
|
5584
|
+
|
|
5585
|
+
<!-- docs-check: new -->
|
|
4405
5586
|
|
|
4406
5587
|
```js
|
|
4407
5588
|
import { createBus } from "nanoeffects";
|
|
@@ -4414,15 +5595,17 @@ bus.on("save", (draft, { store }) => {
|
|
|
4414
5595
|
});
|
|
4415
5596
|
```
|
|
4416
5597
|
|
|
4417
|
-
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.
|
|
4418
5599
|
|
|
4419
5600
|
Library fields (`signal`, `emit`, `run`, …) win on name clashes. `context` may be a function `(task) => fields` when the value depends on the task.
|
|
4420
5601
|
|
|
4421
|
-
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).
|
|
4422
5603
|
|
|
4423
|
-
To give fields to **one**
|
|
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).
|
|
4424
5605
|
|
|
4425
|
-
|
|
5606
|
+
### Type the fields (optional)
|
|
5607
|
+
|
|
5608
|
+
<!-- docs-check: new -->
|
|
4426
5609
|
|
|
4427
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.
|
|
4428
5611
|
|
|
@@ -4444,7 +5627,41 @@ bus.on("save", (draft, { store }) => store.put(draft));
|
|
|
4444
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.
|
|
4445
5628
|
- The plugin returns the same bus, so keep the result of the chain: `const bus = createBus().use(contextPlugin({ store }))`.
|
|
4446
5629
|
|
|
4447
|
-
###
|
|
5630
|
+
### Build a field from the signal of each task
|
|
5631
|
+
|
|
5632
|
+
<!-- docs-check: new -->
|
|
5633
|
+
|
|
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.
|
|
5635
|
+
|
|
5636
|
+
`fromSignal(make)` from `nanoeffects/signal` marks a field of `contextPlugin` as a value that is built from the signal of the task. It does not run `make` for the bus: it runs it for each task that reads the field, once, with the `ctx.signal` of that task.
|
|
5637
|
+
|
|
5638
|
+
```js
|
|
5639
|
+
import { createBus } from "nanoeffects";
|
|
5640
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
5641
|
+
import { fromSignal } from "nanoeffects/signal";
|
|
5642
|
+
import axios from "axios";
|
|
5643
|
+
|
|
5644
|
+
// contextPlugin — every task gets these fields
|
|
5645
|
+
// fromSignal — http is made from the signal of the task that reads ctx.http
|
|
5646
|
+
const bus = createBus().use(
|
|
5647
|
+
contextPlugin({
|
|
5648
|
+
logger: console, // a plain field: the same object for every task
|
|
5649
|
+
http: fromSignal((signal) => axios.create({ signal })),
|
|
5650
|
+
}),
|
|
5651
|
+
);
|
|
5652
|
+
|
|
5653
|
+
bus.on("user/open", async (id, ctx) => {
|
|
5654
|
+
const response = await ctx.http.get(`/users/${id}`); // aborts with the task
|
|
5655
|
+
ctx.logger.log(response.data);
|
|
5656
|
+
});
|
|
5657
|
+
```
|
|
5658
|
+
|
|
5659
|
+
- `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
|
|
5660
|
+
- A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
|
|
5661
|
+
- The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
|
|
5662
|
+
- Without `contextPlugin` a `fromSignal` marker is only an object.
|
|
5663
|
+
|
|
5664
|
+
#### Name the fields in the event map instead
|
|
4448
5665
|
|
|
4449
5666
|
`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.
|
|
4450
5667
|
|
|
@@ -4457,10 +5674,7 @@ type Events = { save: Draft } & WithContext<{ store: Store }>;
|
|
|
4457
5674
|
const bus = createBus<Events>({ context: { store } });
|
|
4458
5675
|
```
|
|
4459
5676
|
|
|
4460
|
-
|
|
4461
|
-
Source: docs/scope.md
|
|
4462
|
-
|
|
4463
|
-
# Pin context fields to an action, a module or a call
|
|
5677
|
+
## Scope: fields for an action, a module and a call
|
|
4464
5678
|
|
|
4465
5679
|
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.
|
|
4466
5680
|
|
|
@@ -4468,7 +5682,9 @@ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`,
|
|
|
4468
5682
|
|
|
4469
5683
|
You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
|
|
4470
5684
|
|
|
4471
|
-
|
|
5685
|
+
### Turn scopes on
|
|
5686
|
+
|
|
5687
|
+
<!-- docs-check: new -->
|
|
4472
5688
|
|
|
4473
5689
|
Scopes are a plugin, so a bus that does not use them pays nothing.
|
|
4474
5690
|
|
|
@@ -4486,7 +5702,9 @@ The bus is in one file and the actions in files named after their domain, so the
|
|
|
4486
5702
|
- Without `scopePlugin` a scope is **ignored**: no error and no warning. If a field is missing from `ctx`, check this first.
|
|
4487
5703
|
- Use it in any order with the other plugins: a scope wins over `effectsPlugin`, `contextPlugin` and `createBus({ context })`.
|
|
4488
5704
|
|
|
4489
|
-
|
|
5705
|
+
### Give an action its own fields
|
|
5706
|
+
|
|
5707
|
+
<!-- docs-check: new -->
|
|
4490
5708
|
|
|
4491
5709
|
<!-- docs-check: continue -->
|
|
4492
5710
|
|
|
@@ -4498,7 +5716,7 @@ import { createAction } from "nanoeffects/actions";
|
|
|
4498
5716
|
export const getUser = createAction(
|
|
4499
5717
|
"user/get",
|
|
4500
5718
|
(id, { api, cache }) => {
|
|
4501
|
-
// api and cache are on ctx for this
|
|
5719
|
+
// api and cache are on ctx for this action only
|
|
4502
5720
|
return cache.get(id) ?? api.getUser(id);
|
|
4503
5721
|
},
|
|
4504
5722
|
{ api, cache: new Map() },
|
|
@@ -4514,7 +5732,9 @@ const user = await bus.emit(getUser("1")); // the handler has api and cache
|
|
|
4514
5732
|
|
|
4515
5733
|
The listeners of `"user/get"` get the same fields when the action is emitted, because the fields belong to the call.
|
|
4516
5734
|
|
|
4517
|
-
|
|
5735
|
+
### Bind an effect to the task
|
|
5736
|
+
|
|
5737
|
+
<!-- docs-check: new -->
|
|
4518
5738
|
|
|
4519
5739
|
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.
|
|
4520
5740
|
|
|
@@ -4559,7 +5779,9 @@ const result = await bus.emit(loadFeed("/feed"));
|
|
|
4559
5779
|
- A function under any other key is copied as it is; a function in `$effects` is bound.
|
|
4560
5780
|
- `$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.
|
|
4561
5781
|
|
|
4562
|
-
|
|
5782
|
+
### Replace a field for one call
|
|
5783
|
+
|
|
5784
|
+
<!-- docs-check: new -->
|
|
4563
5785
|
|
|
4564
5786
|
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.
|
|
4565
5787
|
|
|
@@ -4601,7 +5823,9 @@ Rules for the second argument:
|
|
|
4601
5823
|
- It can replace `$effects` too: `{ $effects: { delay: instantDelay } }`.
|
|
4602
5824
|
- Fields of the library (`signal`, `emit`, …) in it are ignored.
|
|
4603
5825
|
|
|
4604
|
-
|
|
5826
|
+
### Give an action, a job or a module its own fields
|
|
5827
|
+
|
|
5828
|
+
<!-- docs-check: new -->
|
|
4605
5829
|
|
|
4606
5830
|
```js
|
|
4607
5831
|
// user.js
|
|
@@ -4640,20 +5864,24 @@ If the outcome action (`success` or `fail`) has a scope of its own, its listener
|
|
|
4640
5864
|
|
|
4641
5865
|
A module scope does not leak: a listener registered outside the module does not see it.
|
|
4642
5866
|
|
|
4643
|
-
|
|
5867
|
+
### Which value wins
|
|
5868
|
+
|
|
5869
|
+
<!-- docs-check: new -->
|
|
4644
5870
|
|
|
4645
5871
|
When several places set the same field, the strongest one wins:
|
|
4646
5872
|
|
|
4647
5873
|
| Strength | Where it is set |
|
|
4648
5874
|
| -------- | --------------- |
|
|
4649
5875
|
| 1 (strongest) | the second argument of `emit` |
|
|
4650
|
-
| 2 | the scope of the action
|
|
5876
|
+
| 2 | the scope of the action or job |
|
|
4651
5877
|
| 3 | the scope of the module that owns the handler |
|
|
4652
5878
|
| 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
|
|
4653
5879
|
|
|
4654
5880
|
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.
|
|
4655
5881
|
|
|
4656
|
-
|
|
5882
|
+
### Keep a generic effect generic
|
|
5883
|
+
|
|
5884
|
+
<!-- docs-check: new -->
|
|
4657
5885
|
|
|
4658
5886
|
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.
|
|
4659
5887
|
|
|
@@ -4695,7 +5923,9 @@ const pick = createAction(
|
|
|
4695
5923
|
|
|
4696
5924
|
`Effect<F>` and `effect` change what the checker sees. At run time the function is the same.
|
|
4697
5925
|
|
|
4698
|
-
|
|
5926
|
+
### Type the fields of a scope
|
|
5927
|
+
|
|
5928
|
+
<!-- docs-check: new -->
|
|
4699
5929
|
|
|
4700
5930
|
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`.
|
|
4701
5931
|
|
|
@@ -4719,7 +5949,9 @@ const getUser = createAction(
|
|
|
4719
5949
|
- 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.
|
|
4720
5950
|
- `bus.on(creator, fn)` for an action with a scope types `ctx` with its fields.
|
|
4721
5951
|
|
|
4722
|
-
|
|
5952
|
+
### Limits
|
|
5953
|
+
|
|
5954
|
+
<!-- docs-check: new -->
|
|
4723
5955
|
|
|
4724
5956
|
- No plugin, no scope: see [Turn scopes on](#turn-scopes-on).
|
|
4725
5957
|
- 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.
|
|
@@ -4732,6 +5964,8 @@ Source: docs/oop.md
|
|
|
4732
5964
|
|
|
4733
5965
|
# Use the bus from a class
|
|
4734
5966
|
|
|
5967
|
+
> **Level D.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
|
|
5968
|
+
|
|
4735
5969
|
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:
|
|
4736
5970
|
|
|
4737
5971
|
| You want | Use |
|
|
@@ -4739,7 +5973,24 @@ Use this page when **your domain logic lives in classes**: a service, a store or
|
|
|
4739
5973
|
| every `new Service()` to be an independent emitter | `extends Emitter` |
|
|
4740
5974
|
| the instances to share a bus, or to use plugins | `extends extendBus(bus)` |
|
|
4741
5975
|
|
|
4742
|
-
If your logic is plain functions, stay with [modules](actions.md#
|
|
5976
|
+
If your logic is plain functions, stay with [modules](actions.md#modules): a module is the same idea without a class.
|
|
5977
|
+
|
|
5978
|
+
## On this page
|
|
5979
|
+
|
|
5980
|
+
- [The problem without it](#the-problem-without-it)
|
|
5981
|
+
- [Make an independent emitter with `Emitter`](#make-an-independent-emitter-with-emitter)
|
|
5982
|
+
- [Type the events (optional)](#type-the-events-optional)
|
|
5983
|
+
- [Give a class the methods of a shared bus](#give-a-class-the-methods-of-a-shared-bus)
|
|
5984
|
+
- [Extend the result with no class of your own](#extend-the-result-with-no-class-of-your-own)
|
|
5985
|
+
- [Let the class make its bus](#let-the-class-make-its-bus)
|
|
5986
|
+
- [End the subscriptions with `dispose`](#end-the-subscriptions-with-dispose)
|
|
5987
|
+
- [Stream an event with a `Subject`](#stream-an-event-with-a-subject)
|
|
5988
|
+
- [Keep a method of your own](#keep-a-method-of-your-own)
|
|
5989
|
+
- [Subscribe a method with `@On` and `@Once`](#subscribe-a-method-with-on-and-once)
|
|
5990
|
+
- [Decorate the class with `@WithBus`](#decorate-the-class-with-withbus)
|
|
5991
|
+
- [Pass a method by hand](#pass-a-method-by-hand)
|
|
5992
|
+
- [Type the instances (optional)](#type-the-instances-optional)
|
|
5993
|
+
- [What is not done](#what-is-not-done)
|
|
4743
5994
|
|
|
4744
5995
|
## The problem without it
|
|
4745
5996
|
|
|
@@ -5148,6 +6399,8 @@ Source: docs/typescript.md
|
|
|
5148
6399
|
|
|
5149
6400
|
# Use nanoeffects in a TypeScript app with many files
|
|
5150
6401
|
|
|
6402
|
+
> **Level D.** Read first: [Guide](guide.md#10-add-types-when-you-want-checking-optional).
|
|
6403
|
+
|
|
5151
6404
|
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.
|
|
5152
6405
|
|
|
5153
6406
|
## Write an action with its handler
|
|
@@ -5171,7 +6424,7 @@ export const getUser = createAction(
|
|
|
5171
6424
|
},
|
|
5172
6425
|
);
|
|
5173
6426
|
|
|
5174
|
-
// createModule: m.use binds the handler the
|
|
6427
|
+
// createModule: m.use binds the handler the action carries, while the module is mounted
|
|
5175
6428
|
export const userModule = createModule("user", (m) => {
|
|
5176
6429
|
m.use(getUser);
|
|
5177
6430
|
});
|
|
@@ -5320,6 +6573,8 @@ Source: docs/migrating-from-saga.md
|
|
|
5320
6573
|
|
|
5321
6574
|
# Coming from redux-saga
|
|
5322
6575
|
|
|
6576
|
+
> **Level E, for readers who know redux-saga.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
6577
|
+
|
|
5323
6578
|
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).
|
|
5324
6579
|
|
|
5325
6580
|
## Find the equivalent
|
|
@@ -5419,8 +6674,21 @@ Source: docs/migrating-from-effector.md
|
|
|
5419
6674
|
|
|
5420
6675
|
# Coming from Effector
|
|
5421
6676
|
|
|
6677
|
+
> **Level E, for readers who know Effector.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
6678
|
+
|
|
5422
6679
|
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.
|
|
5423
6680
|
|
|
6681
|
+
## On this page
|
|
6682
|
+
|
|
6683
|
+
- [Split the work](#split-the-work)
|
|
6684
|
+
- [Find the equivalent](#find-the-equivalent)
|
|
6685
|
+
- [Rewrite an effect](#rewrite-an-effect)
|
|
6686
|
+
- [Keep the state in Zustand](#keep-the-state-in-zustand)
|
|
6687
|
+
- [Rewrite a form](#rewrite-a-form)
|
|
6688
|
+
- [Replace the log with a request](#replace-the-log-with-a-request)
|
|
6689
|
+
- [Use both in one app](#use-both-in-one-app)
|
|
6690
|
+
- [What is different](#what-is-different)
|
|
6691
|
+
|
|
5424
6692
|
## Split the work
|
|
5425
6693
|
|
|
5426
6694
|
| Job | Use |
|
|
@@ -5439,7 +6707,7 @@ nanoeffects has no store. `createStore` can be replaced by any store you like, f
|
|
|
5439
6707
|
| event call `loaded(user)` | `bus.emit("loaded", user)` |
|
|
5440
6708
|
| `createEffect(handler)` | a handler: `bus.on(event, handler)` |
|
|
5441
6709
|
| effect with `done` and `fail` | [`createJob`](actions.md): emits the success and fail actions |
|
|
5442
|
-
| `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#
|
|
6710
|
+
| `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)) |
|
|
5443
6711
|
| `sample({ clock, fn, target })` | `bus.on(clock, handler)` and `ctx.emit(target, …)` |
|
|
5444
6712
|
| `sample({ source: $store, clock })` | read the store from a [context field](context.md) in the handler |
|
|
5445
6713
|
| `sample({ filter })` | an `if` in the handler |
|
|
@@ -5737,7 +7005,7 @@ bus.on(
|
|
|
5737
7005
|
## What is different
|
|
5738
7006
|
|
|
5739
7007
|
- **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)).
|
|
5740
|
-
- **`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#
|
|
7008
|
+
- **`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).
|
|
5741
7009
|
- **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.
|
|
5742
7010
|
- **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.
|
|
5743
7011
|
- **No graph.** `sample` and `forward` build a static graph. A bus subscribes handlers at runtime and the call order is the order of `on`.
|
|
@@ -5747,9 +7015,11 @@ bus.on(
|
|
|
5747
7015
|
---
|
|
5748
7016
|
Source: docs/migrating-from-mitt.md
|
|
5749
7017
|
|
|
5750
|
-
# Coming from mitt
|
|
7018
|
+
# Coming from mitt and nanoevents
|
|
7019
|
+
|
|
7020
|
+
> **Level A, for readers who know mitt or nanoevents.** Read first: nothing: the bus is the same idea.
|
|
5751
7021
|
|
|
5752
|
-
Read this page if your app uses a small event emitter (`mitt`, `nanoevents`,
|
|
7022
|
+
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.
|
|
5753
7023
|
|
|
5754
7024
|
## When to switch
|
|
5755
7025
|
|
|
@@ -5764,8 +7034,8 @@ If handlers stay synchronous, keep your emitter. nanoeffects costs about 1.4 kB
|
|
|
5764
7034
|
|
|
5765
7035
|
## Find the equivalent
|
|
5766
7036
|
|
|
5767
|
-
| mitt /
|
|
5768
|
-
|
|
|
7037
|
+
| mitt / nanoevents | nanoeffects |
|
|
7038
|
+
| ----------------- | ----------- |
|
|
5769
7039
|
| `emitter.on("a", handler)` | `bus.on("a", handler)` |
|
|
5770
7040
|
| `emitter.off("a", handler)` | call the function that `bus.on` returned, or `bus.off("a")` for all handlers of `a` |
|
|
5771
7041
|
| `emitter.emit("a", payload)` | `bus.emit("a", payload)` |
|
|
@@ -5850,6 +7120,8 @@ Source: docs/migrating-from-rxjs.md
|
|
|
5850
7120
|
|
|
5851
7121
|
# Use nanoeffects next to RxJS
|
|
5852
7122
|
|
|
7123
|
+
> **Level E, for readers who know RxJS.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 3.
|
|
7124
|
+
|
|
5853
7125
|
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.
|
|
5854
7126
|
|
|
5855
7127
|
## Split the work
|
|
@@ -6049,23 +7321,340 @@ Source: docs/semantics.md
|
|
|
6049
7321
|
|
|
6050
7322
|
# Semantics
|
|
6051
7323
|
|
|
7324
|
+
> **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 to 6.
|
|
7325
|
+
|
|
6052
7326
|
Guarantees and sharp edges. Read when something surprises you; not required to start.
|
|
6053
7327
|
|
|
7328
|
+
## Five rules to know first
|
|
7329
|
+
|
|
7330
|
+
1. A handler is `(payload, ctx)`. Use `ctx.signal`, `ctx.emit` and `ctx.on`; the rest of `ctx` is optional.
|
|
7331
|
+
2. Pass `ctx.signal` to what the handler calls (`fetch`, a client), so an abort stops the work.
|
|
7332
|
+
3. An aborted task delivers no result and emits nothing.
|
|
7333
|
+
4. `emit` is synchronous: the first handler error is thrown, unless `onError` is set.
|
|
7334
|
+
5. Rejections of async handlers are not handled without `onError`.
|
|
7335
|
+
|
|
7336
|
+
Details of each rule and the rest:
|
|
7337
|
+
|
|
6054
7338
|
- **An aborted task delivers no result.** If the task aborts before a promise settles, the promise rejects with the abort reason, even if the value was just produced in the same tick. `latest`, `debounce`, `all`, `race`, `take`, and the rest follow this rule.
|
|
6055
7339
|
- **`emit` is a plain, synchronous loop** (see [Errors](errors.md)). A `ctx.emit` inside a handler is the same call: without `onError`, an error of a nested handler is thrown into the handler that emitted; with `onError` it goes there and `ctx.emit` does not throw.
|
|
6056
7340
|
- **Rejections of async handlers** are not handled for you. Without `onError` they are unhandled rejections, which end a Node process by default. Use `onError` for async handlers.
|
|
6057
7341
|
- **`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.
|
|
6058
7342
|
- **Effects start their function synchronously**, so an event emitted right after the call reaches a `take` inside it.
|
|
6059
7343
|
|
|
7344
|
+
---
|
|
7345
|
+
Source: docs/pass-the-signal.md
|
|
7346
|
+
|
|
7347
|
+
# Keep the signal through a handler
|
|
7348
|
+
|
|
7349
|
+
> **Level B.** Read first: [Policies](policies.md#latest).
|
|
7350
|
+
|
|
7351
|
+
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.
|
|
7352
|
+
|
|
7353
|
+
## On this page
|
|
7354
|
+
|
|
7355
|
+
- [Know what the signal does](#know-what-the-signal-does)
|
|
7356
|
+
- [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
|
|
7357
|
+
- [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
|
|
7358
|
+
- [Turn on the checks](#turn-on-the-checks)
|
|
7359
|
+
- [Choose which calls are checked](#choose-which-calls-are-checked)
|
|
7360
|
+
- [Count lost signals in a test](#count-lost-signals-in-a-test)
|
|
7361
|
+
- [Prove it with a test](#prove-it-with-a-test)
|
|
7362
|
+
- [Know what the checks cannot see](#know-what-the-checks-cannot-see)
|
|
7363
|
+
|
|
7364
|
+
## Know what the signal does
|
|
7365
|
+
|
|
7366
|
+
`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.
|
|
7367
|
+
|
|
7368
|
+
## Fix a handler that loses the signal
|
|
7369
|
+
|
|
7370
|
+
The two shapes you meet first, and the fix:
|
|
7371
|
+
|
|
7372
|
+
```js
|
|
7373
|
+
import { createBus } from "nanoeffects";
|
|
7374
|
+
import { latest } from "nanoeffects/policies";
|
|
7375
|
+
|
|
7376
|
+
const bus = createBus();
|
|
7377
|
+
|
|
7378
|
+
// 1. ctx is taken and the signal is lost: the request cannot stop
|
|
7379
|
+
bus.on(
|
|
7380
|
+
"search",
|
|
7381
|
+
latest(async (query, ctx) => {
|
|
7382
|
+
const items = await api.search(query);
|
|
7383
|
+
ctx.emit("results", items);
|
|
7384
|
+
}),
|
|
7385
|
+
);
|
|
7386
|
+
|
|
7387
|
+
// 2. one step passes it and another does not: the first one cannot stop
|
|
7388
|
+
bus.on(
|
|
7389
|
+
"feed",
|
|
7390
|
+
latest(async (query, ctx) => {
|
|
7391
|
+
const news = await api.news(query);
|
|
7392
|
+
const posts = await api.posts(query, { signal: ctx.signal });
|
|
7393
|
+
ctx.emit("results", { news, posts });
|
|
7394
|
+
}),
|
|
7395
|
+
);
|
|
7396
|
+
|
|
7397
|
+
// the fix: every step that waits gets the signal
|
|
7398
|
+
bus.on(
|
|
7399
|
+
"feed/fixed",
|
|
7400
|
+
latest(async (query, ctx) => {
|
|
7401
|
+
const news = await api.news(query, { signal: ctx.signal });
|
|
7402
|
+
const posts = await api.posts(query, { signal: ctx.signal });
|
|
7403
|
+
ctx.emit("results", { news, posts });
|
|
7404
|
+
}),
|
|
7405
|
+
);
|
|
7406
|
+
```
|
|
7407
|
+
|
|
7408
|
+
## Pass the signal when it is hard
|
|
7409
|
+
|
|
7410
|
+
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)).
|
|
7411
|
+
|
|
7412
|
+
| Situation | What to do | Lint | devPlugin |
|
|
7413
|
+
| --------- | ---------- | :--: | :-------: |
|
|
7414
|
+
| The handler has no `ctx`: `latest(async (query) => ...)` | Take `(query, ctx)` and pass `ctx.signal` | yes | |
|
|
7415
|
+
| `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request | yes | yes |
|
|
7416
|
+
| One step passes the signal, another does not | Pass it to every step | yes | yes |
|
|
7417
|
+
| The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
|
|
7418
|
+
| Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
|
|
7419
|
+
| `Promise.all(ids.map((id) => load(id)))` | Pass it inside the callback | yes | |
|
|
7420
|
+
| 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 | |
|
|
7421
|
+
| `timeout(ctx, 1000, (child) => api.search(q))` | Pass `child`, or `child.signal`, to the request | yes | |
|
|
7422
|
+
| `ctx.run((child) => api.search(q))` | Pass `child` | yes | |
|
|
7423
|
+
| A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
|
|
7424
|
+
| A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
|
|
7425
|
+
| A chain: `api.news(q).then(...)` | Pass the signal to the first call | yes | |
|
|
7426
|
+
| 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 | |
|
|
7427
|
+
| Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
|
|
7428
|
+
| 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 | |
|
|
7429
|
+
| 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 | |
|
|
7430
|
+
| The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
|
|
7431
|
+
| `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
|
|
7432
|
+
| Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
|
|
7433
|
+
| `ctx.on` after the cancel | The same | | yes |
|
|
7434
|
+
|
|
7435
|
+
The pause and the effect rows in code:
|
|
7436
|
+
|
|
7437
|
+
```js
|
|
7438
|
+
import { createBus } from "nanoeffects";
|
|
7439
|
+
import { delay, retry, timeout } from "nanoeffects/effects";
|
|
7440
|
+
import { latest } from "nanoeffects/policies";
|
|
7441
|
+
|
|
7442
|
+
const bus = createBus();
|
|
7443
|
+
|
|
7444
|
+
bus.on(
|
|
7445
|
+
"search",
|
|
7446
|
+
latest(async (query, ctx) => {
|
|
7447
|
+
// delay — a pause that stops with ctx, not new Promise(setTimeout)
|
|
7448
|
+
await delay(ctx, 300);
|
|
7449
|
+
|
|
7450
|
+
// retry — the attempts stop with ctx, but the request needs the signal too
|
|
7451
|
+
const items = await retry(ctx, () => api.search(query, { signal: ctx.signal }));
|
|
7452
|
+
|
|
7453
|
+
// timeout — the callback gets a child context: give it to the request
|
|
7454
|
+
const details = await timeout(ctx, 5000, (child) => api.details(query, child));
|
|
7455
|
+
|
|
7456
|
+
ctx.emit("results", { items, details });
|
|
7457
|
+
}),
|
|
7458
|
+
);
|
|
7459
|
+
```
|
|
7460
|
+
|
|
7461
|
+
Calls that run together, and a signal that joins another:
|
|
7462
|
+
|
|
7463
|
+
```js
|
|
7464
|
+
import { createBus } from "nanoeffects";
|
|
7465
|
+
import { latest } from "nanoeffects/policies";
|
|
7466
|
+
|
|
7467
|
+
const bus = createBus();
|
|
7468
|
+
|
|
7469
|
+
bus.on(
|
|
7470
|
+
"feed",
|
|
7471
|
+
latest(async (query, ctx) => {
|
|
7472
|
+
// each call of the group gets the signal, not only the first
|
|
7473
|
+
const [news, posts] = await Promise.all([
|
|
7474
|
+
api.news(query, { signal: ctx.signal }),
|
|
7475
|
+
api.posts(query, { signal: ctx.signal }),
|
|
7476
|
+
]);
|
|
7477
|
+
|
|
7478
|
+
// AbortSignal.any — stops with the handler or after 5 seconds, whichever comes first
|
|
7479
|
+
const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)]);
|
|
7480
|
+
const details = await api.details(query, { signal });
|
|
7481
|
+
|
|
7482
|
+
ctx.emit("results", { news, posts, details });
|
|
7483
|
+
}),
|
|
7484
|
+
);
|
|
7485
|
+
```
|
|
7486
|
+
|
|
7487
|
+
## Turn on the checks
|
|
7488
|
+
|
|
7489
|
+
**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:
|
|
7490
|
+
|
|
7491
|
+
<!-- docs-check: skip - a config file of the reader's project -->
|
|
7492
|
+
```js
|
|
7493
|
+
// eslint.config.mjs
|
|
7494
|
+
import nanoeffects from "./nanoeffects-lint.mjs";
|
|
7495
|
+
|
|
7496
|
+
export default [
|
|
7497
|
+
{
|
|
7498
|
+
files: ["**/*.{js,mjs,cjs,jsx,ts,tsx}"],
|
|
7499
|
+
plugins: { nanoeffects },
|
|
7500
|
+
rules: {
|
|
7501
|
+
"nanoeffects/policy-handler-uses-signal": [
|
|
7502
|
+
"warn",
|
|
7503
|
+
// calls that take no signal and are fine to wait for
|
|
7504
|
+
{ ignore: ["cache.get", "storage.*"] },
|
|
7505
|
+
],
|
|
7506
|
+
},
|
|
7507
|
+
},
|
|
7508
|
+
];
|
|
7509
|
+
```
|
|
7510
|
+
|
|
7511
|
+
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.
|
|
7512
|
+
|
|
7513
|
+
**While you run.** `devPlugin` warns once when a handler emits, or subscribes with `ctx.on`, after its task was cancelled:
|
|
7514
|
+
|
|
7515
|
+
```js
|
|
7516
|
+
import { createBus } from "nanoeffects";
|
|
7517
|
+
import { devPlugin } from "nanoeffects/dev";
|
|
7518
|
+
|
|
7519
|
+
const bus = createBus();
|
|
7520
|
+
|
|
7521
|
+
// devPlugin — warns about a handler that kept running after it was cancelled; nothing in production
|
|
7522
|
+
bus.use(devPlugin());
|
|
7523
|
+
```
|
|
7524
|
+
|
|
7525
|
+
## Choose which calls are checked
|
|
7526
|
+
|
|
7527
|
+
The rule cannot tell, from the call alone, whether the function takes a signal. Three ways to tell it:
|
|
7528
|
+
|
|
7529
|
+
- **`ignore`** lists the calls to skip, by name: `ignore: ["cache.get", "storage.*"]`. Use it when most calls take a signal and a few cannot.
|
|
7530
|
+
- **`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.
|
|
7531
|
+
- **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.
|
|
7532
|
+
|
|
7533
|
+
<!-- docs-check: skip - a config file of the reader's project -->
|
|
7534
|
+
```js
|
|
7535
|
+
// eslint.config.mjs
|
|
7536
|
+
import parser from "@typescript-eslint/parser";
|
|
7537
|
+
import nanoeffects from "./nanoeffects-lint.mjs";
|
|
7538
|
+
|
|
7539
|
+
export default [
|
|
7540
|
+
{
|
|
7541
|
+
files: ["**/*.{ts,tsx}"],
|
|
7542
|
+
// the parser gives the rule the type checker of your project
|
|
7543
|
+
languageOptions: { parser, parserOptions: { project: "./tsconfig.json" } },
|
|
7544
|
+
plugins: { nanoeffects },
|
|
7545
|
+
rules: { "nanoeffects/policy-handler-uses-signal": "warn" },
|
|
7546
|
+
},
|
|
7547
|
+
];
|
|
7548
|
+
```
|
|
7549
|
+
|
|
7550
|
+
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.
|
|
7551
|
+
|
|
7552
|
+
## Count lost signals in a test
|
|
7553
|
+
|
|
7554
|
+
`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:
|
|
7555
|
+
|
|
7556
|
+
```js
|
|
7557
|
+
import { createBus } from "nanoeffects";
|
|
7558
|
+
import { devPlugin, getLostSignalsCount, getLostSignals } from "nanoeffects/dev";
|
|
7559
|
+
import { latest } from "nanoeffects/policies";
|
|
7560
|
+
import { expect, test } from "vitest";
|
|
7561
|
+
|
|
7562
|
+
test("a newer search leaves no lost signals", async () => {
|
|
7563
|
+
const bus = createBus();
|
|
7564
|
+
// devPlugin — records every emit that a cancelled handler made; log hides its warnings in the test
|
|
7565
|
+
bus.use(devPlugin({ log: () => {} }));
|
|
7566
|
+
bus.on(
|
|
7567
|
+
"search",
|
|
7568
|
+
latest(async (query, ctx) => {
|
|
7569
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
7570
|
+
ctx.emit("results", items);
|
|
7571
|
+
}),
|
|
7572
|
+
);
|
|
7573
|
+
|
|
7574
|
+
bus.emit("search", "a");
|
|
7575
|
+
bus.emit("search", "ab"); // cancels the first call
|
|
7576
|
+
|
|
7577
|
+
// wait — how long to let the handlers finish before counting, in ms
|
|
7578
|
+
const count = await getLostSignalsCount(bus, { wait: 50 });
|
|
7579
|
+
|
|
7580
|
+
expect(count).toBe(0);
|
|
7581
|
+
});
|
|
7582
|
+
```
|
|
7583
|
+
|
|
7584
|
+
`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.
|
|
7585
|
+
|
|
7586
|
+
It counts what a handler does after the cancel. A request that a handler started and that the cancel did not stop is not counted unless the handler then emits; the next section finds those.
|
|
7587
|
+
|
|
7588
|
+
## Prove it with a test
|
|
7589
|
+
|
|
7590
|
+
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.
|
|
7591
|
+
|
|
7592
|
+
<!-- docs-check: skip - a test file of the reader's project -->
|
|
7593
|
+
```js
|
|
7594
|
+
// search.cancel.test.js
|
|
7595
|
+
import { createBus } from "nanoeffects";
|
|
7596
|
+
import { latest } from "nanoeffects/policies";
|
|
7597
|
+
import { expect, test } from "vitest";
|
|
7598
|
+
// copy prove-cancel.js from examples/lint; it needs no package
|
|
7599
|
+
import { requestsThatKeptRunning } from "./prove-cancel.js";
|
|
7600
|
+
|
|
7601
|
+
test("a newer search stops the older one at every step", async () => {
|
|
7602
|
+
const bus = createBus();
|
|
7603
|
+
bus.on(
|
|
7604
|
+
"search",
|
|
7605
|
+
latest(async (query, ctx) => {
|
|
7606
|
+
const news = await api.news(query, { signal: ctx.signal });
|
|
7607
|
+
const posts = await api.posts(query, { signal: ctx.signal });
|
|
7608
|
+
ctx.emit("results", { news, posts });
|
|
7609
|
+
}),
|
|
7610
|
+
);
|
|
7611
|
+
|
|
7612
|
+
// for each step: start a call, cancel it with a newer one, and collect the requests that did not stop
|
|
7613
|
+
const kept = await requestsThatKeptRunning((value) => bus.emit("search", value));
|
|
7614
|
+
|
|
7615
|
+
// empty when every request got a signal that aborted with the handler
|
|
7616
|
+
expect(kept).toEqual([]);
|
|
7617
|
+
});
|
|
7618
|
+
```
|
|
7619
|
+
|
|
7620
|
+
`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.
|
|
7621
|
+
|
|
7622
|
+
## Know what the checks cannot see
|
|
7623
|
+
|
|
7624
|
+
- **A handler that is not written in place.** `latest(searchHandler)` and `latest(withLogging(async (query, ctx) => ...))` are not read by the linter.
|
|
7625
|
+
- **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.
|
|
7626
|
+
- **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.
|
|
7627
|
+
- **Shadowed names.** The rule uses no scope analysis, so an inner variable named `signal` can hide a lost signal.
|
|
7628
|
+
|
|
6060
7629
|
---
|
|
6061
7630
|
Source: docs/tracing.md
|
|
6062
7631
|
|
|
6063
7632
|
# Trace tasks
|
|
6064
7633
|
|
|
7634
|
+
> **Level F.** Read first: [Guide](guide.md#1-create-a-bus) steps 1 and 2.
|
|
7635
|
+
|
|
6065
7636
|
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.
|
|
6066
7637
|
|
|
6067
7638
|
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.
|
|
6068
7639
|
|
|
7640
|
+
## On this page
|
|
7641
|
+
|
|
7642
|
+
- [Attach a tracer](#attach-a-tracer)
|
|
7643
|
+
- [Show every emit](#show-every-emit)
|
|
7644
|
+
- [Show which subscription began a chain](#show-which-subscription-began-a-chain)
|
|
7645
|
+
- [Follow one flow with a traceGroupId](#follow-one-flow-with-a-tracegroupid)
|
|
7646
|
+
- [Name a listener](#name-a-listener)
|
|
7647
|
+
- [Keep the chain: `ctx.emit`, not `bus.emit`](#keep-the-chain-ctxemit-not-busemit)
|
|
7648
|
+
- [Turn the names off](#turn-the-names-off)
|
|
7649
|
+
- [Use built-in loggers](#use-built-in-loggers)
|
|
7650
|
+
- [Cut volume in production](#cut-volume-in-production)
|
|
7651
|
+
- [Keep the last events for an incident](#keep-the-last-events-for-an-incident)
|
|
7652
|
+
- [Configure Redux DevTools](#configure-redux-devtools)
|
|
7653
|
+
- [Find loops with `graphLogger`](#find-loops-with-graphlogger)
|
|
7654
|
+
- [Send safe data to a tool](#send-safe-data-to-a-tool)
|
|
7655
|
+
- [Write your own logger](#write-your-own-logger)
|
|
7656
|
+
- [Drop tracing from a build](#drop-tracing-from-a-build)
|
|
7657
|
+
|
|
6069
7658
|
## Attach a tracer
|
|
6070
7659
|
|
|
6071
7660
|
Pass `trace` to `createBus`. Make it with `createTracer` from `nanoeffects/trace`, which takes a logger (a function) or an array of them. The tracing code lives in that entry, so a bus without `trace` does not carry it.
|
|
@@ -6101,7 +7690,7 @@ Phases:
|
|
|
6101
7690
|
- `cancel` — aborted after it started (superseded by `latest`, parent aborted, lost a `race`).
|
|
6102
7691
|
- `drop` — never started (`leading` while busy, a `throttle` call replaced by a newer one, a `debounce` call replaced before its delay ended).
|
|
6103
7692
|
|
|
6104
|
-
`all` and `race` are traced when you pass them the handler `ctx`: the group is one task; every
|
|
7693
|
+
`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.
|
|
6105
7694
|
|
|
6106
7695
|
```js
|
|
6107
7696
|
import { createBus } from "nanoeffects";
|
|
@@ -6148,7 +7737,7 @@ The event changes nothing else: the other tasks keep their `parentId`, `rootId`
|
|
|
6148
7737
|
|
|
6149
7738
|
## Show which subscription began a chain
|
|
6150
7739
|
|
|
6151
|
-
**Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of
|
|
7740
|
+
**Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of actions and retries.
|
|
6152
7741
|
|
|
6153
7742
|
**When.** The bus has a tracer and the project has several `useEmitter` or `bus.emitter` calls. The names come from `tracePlugin`: use it on the bus, or the `traceId` is ignored.
|
|
6154
7743
|
|
|
@@ -6176,8 +7765,8 @@ In a component, the same option goes in the options of the hook: `useEmitter(nam
|
|
|
6176
7765
|
|
|
6177
7766
|
```
|
|
6178
7767
|
[nanoeffects] Profile · run start
|
|
6179
|
-
[nanoeffects] user/save ·
|
|
6180
|
-
[nanoeffects] user/save ·
|
|
7768
|
+
[nanoeffects] user/save · action start
|
|
7769
|
+
[nanoeffects] user/save · action done
|
|
6181
7770
|
[nanoeffects] Profile · run done
|
|
6182
7771
|
```
|
|
6183
7772
|
|
|
@@ -6217,7 +7806,7 @@ const pay = createAction("checkout/pay", async () => {}, {
|
|
|
6217
7806
|
```
|
|
6218
7807
|
|
|
6219
7808
|
```
|
|
6220
|
-
[nanoeffects] [checkout] checkout/pay ·
|
|
7809
|
+
[nanoeffects] [checkout] checkout/pay · action start
|
|
6221
7810
|
```
|
|
6222
7811
|
|
|
6223
7812
|
The emitters take it too: `useEmitter(cartChanged, cart, { traceId: "Cart", traceGroupId: "checkout" })`. `traceGroupId` works without `scopePlugin`, and needs `tracePlugin`.
|
|
@@ -6247,7 +7836,7 @@ bus.once("name/changed", (name) => console.log(name), { traceId: "auditLog" });
|
|
|
6247
7836
|
|
|
6248
7837
|
```
|
|
6249
7838
|
[nanoeffects] saveNotifier · run start
|
|
6250
|
-
[nanoeffects] user/save ·
|
|
7839
|
+
[nanoeffects] user/save · action start
|
|
6251
7840
|
[nanoeffects] saveNotifier · run done
|
|
6252
7841
|
```
|
|
6253
7842
|
|
|
@@ -6292,7 +7881,8 @@ const bus = createBus({
|
|
|
6292
7881
|
| `reduxDevtools()` | Sends the emits to Redux DevTools, and with `policies` the stages of the handlers as `type/effect/phase`; state is `{ running }` |
|
|
6293
7882
|
| `consoleLogger()` | Prints one line per phase, indented by task depth |
|
|
6294
7883
|
| `performanceLogger()` | User Timing measures for the browser Performance panel |
|
|
6295
|
-
| `createTraceRecorder()` | Keeps events in `recorder.events
|
|
7884
|
+
| `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)) |
|
|
7885
|
+
| `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)) |
|
|
6296
7886
|
|
|
6297
7887
|
### Cut volume in production
|
|
6298
7888
|
|
|
@@ -6305,11 +7895,63 @@ createTracer(logger, {
|
|
|
6305
7895
|
});
|
|
6306
7896
|
```
|
|
6307
7897
|
|
|
6308
|
-
- `sample` is decided once per root task; every task below it follows, so a trace is never cut in the middle. Unsampled tasks cost almost nothing.
|
|
6309
|
-
- `filter` runs on each event before any logger sees it.
|
|
7898
|
+
- `sample` is decided once per root task; every task below it follows, so a trace is never cut in the middle. Unsampled tasks cost almost nothing.
|
|
7899
|
+
- `filter` runs on each event before any logger sees it.
|
|
7900
|
+
|
|
7901
|
+
### Keep the last events for an incident
|
|
7902
|
+
|
|
7903
|
+
`createTraceRecorder()` with no option keeps every event, so a long session fills memory. That is fine in a test and wrong in a running app. Give it a `capacity` and it keeps only the last N events (a ring buffer): when something goes wrong, you still have what led up to it.
|
|
7904
|
+
|
|
7905
|
+
<!-- docs-check: new -->
|
|
7906
|
+
|
|
7907
|
+
```js
|
|
7908
|
+
// bus.js
|
|
7909
|
+
import { createBus } from "nanoeffects";
|
|
7910
|
+
import { createTraceRecorder, createTracer } from "nanoeffects/trace";
|
|
7911
|
+
|
|
7912
|
+
// A logger that keeps the last 500 events and drops the older ones.
|
|
7913
|
+
export const recorder = createTraceRecorder({ capacity: 500 });
|
|
7914
|
+
|
|
7915
|
+
// Feed the recorder from the tracer.
|
|
7916
|
+
const trace = createTracer(recorder);
|
|
7917
|
+
|
|
7918
|
+
export const bus = createBus({ trace });
|
|
7919
|
+
```
|
|
7920
|
+
|
|
7921
|
+
```js
|
|
7922
|
+
// incident.js
|
|
7923
|
+
import { recorder } from "./bus";
|
|
7924
|
+
|
|
7925
|
+
// Keep what is safe to send. An event also has `payload` and `error`.
|
|
7926
|
+
const withoutPayload = ({ id, parentId, type, effect, phase }) => ({
|
|
7927
|
+
id,
|
|
7928
|
+
parentId,
|
|
7929
|
+
type,
|
|
7930
|
+
effect,
|
|
7931
|
+
phase,
|
|
7932
|
+
});
|
|
7933
|
+
|
|
7934
|
+
window.addEventListener("error", () => {
|
|
7935
|
+
const events = recorder.events; // a snapshot, oldest event first
|
|
7936
|
+
const safe = events.map(withoutPayload);
|
|
7937
|
+
const body = JSON.stringify(safe);
|
|
7938
|
+
navigator.sendBeacon("/api/incident", body);
|
|
7939
|
+
});
|
|
7940
|
+
```
|
|
7941
|
+
|
|
7942
|
+
What to know before you send the events:
|
|
7943
|
+
|
|
7944
|
+
- **`payload` and `error` can hold private data**: what a user typed, tokens, personal text. The recorder keeps them as they are and does not redact. Choose what to send, as `withoutPayload` does.
|
|
7945
|
+
- **There is no time on an event.** You get the order, not the duration. To add a time, wrap the recorder in a logger that stamps `performance.now()` on a copy of the event.
|
|
7946
|
+
- **An old `parentId` can point at an event that was pushed out.** When you build a tree from the events, treat an unknown `parentId` as a root.
|
|
7947
|
+
- **`recorder.events` is a snapshot with a `capacity`.** Each read builds a new array, so read it once at the moment you need it. Without a `capacity` it is one live array.
|
|
7948
|
+
- **A low `sample` thins the buffer.** `createTracer(recorder, { sample: 0.05 })` records 5% of root tasks, so the buffer may hold little when the incident comes. See [Cut volume in production](#cut-volume-in-production).
|
|
7949
|
+
- **A `capacity` that is not a positive integer** (`0`, a negative number, `NaN`) means no limit.
|
|
6310
7950
|
|
|
6311
7951
|
### Configure Redux DevTools
|
|
6312
7952
|
|
|
7953
|
+
<!-- docs-check: new -->
|
|
7954
|
+
|
|
6313
7955
|
Read-only (the bus owns no state, so there is no time travel). Does nothing when the extension is not installed. Has its own instance in the DevTools picker, apart from your store.
|
|
6314
7956
|
|
|
6315
7957
|
Quiet by default, so you see what happened before you see how:
|
|
@@ -6373,6 +8015,58 @@ reduxDevtools({
|
|
|
6373
8015
|
|
|
6374
8016
|
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.
|
|
6375
8017
|
|
|
8018
|
+
### Find loops with `graphLogger`
|
|
8019
|
+
|
|
8020
|
+
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.
|
|
8021
|
+
|
|
8022
|
+
```js
|
|
8023
|
+
// bus.js
|
|
8024
|
+
import { createBus } from "nanoeffects";
|
|
8025
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
8026
|
+
|
|
8027
|
+
// graphLogger — the map of events and the loop finder, in one logger
|
|
8028
|
+
export const graph = graphLogger({ name: "app" });
|
|
8029
|
+
|
|
8030
|
+
// createTracer — sends every task of the bus to the logger
|
|
8031
|
+
const trace = createTracer(graph);
|
|
8032
|
+
|
|
8033
|
+
// tracePlugin — also reports each emit; the logger works without it, with a coarser map
|
|
8034
|
+
export const bus = createBus({ trace }).use(tracePlugin());
|
|
8035
|
+
|
|
8036
|
+
// graph.cycles() lists the loops found; graph.toMermaid() draws the map
|
|
8037
|
+
```
|
|
8038
|
+
|
|
8039
|
+
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).
|
|
8040
|
+
|
|
8041
|
+
### Send safe data to a tool
|
|
8042
|
+
|
|
8043
|
+
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:
|
|
8044
|
+
|
|
8045
|
+
| Logger | What it does with a payload and an error |
|
|
8046
|
+
| ------ | ---------------------------------------- |
|
|
8047
|
+
| `reduxDevtools()` | Makes both plain data with `safeSerialize` before the extension gets them. An `Error` is sent as its message. `serialize` changes this. |
|
|
8048
|
+
| `performanceLogger()` | Sends ids and the phase; an error is text. A payload is not sent. |
|
|
8049
|
+
| `consoleLogger()` | Passes the value to `console`, which prints any value and keeps it open for you to inspect. |
|
|
8050
|
+
| `createTraceRecorder()` | Keeps the events as they are. `recorder.toJSON()` and `JSON.stringify(recorder)` give plain data. |
|
|
8051
|
+
| `graphLogger()` | Exports no payload unless you ask, and then makes it plain. |
|
|
8052
|
+
|
|
8053
|
+
`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.
|
|
8054
|
+
|
|
8055
|
+
```js
|
|
8056
|
+
import { createTracer, reduxDevtools, safeSerialize } from "nanoeffects/trace";
|
|
8057
|
+
|
|
8058
|
+
const trace = createTracer(
|
|
8059
|
+
reduxDevtools({
|
|
8060
|
+
name: "app",
|
|
8061
|
+
// serialize — runs on every payload and error before the extension gets it;
|
|
8062
|
+
// hide a secret first, then make the rest plain
|
|
8063
|
+
serialize: (value) => safeSerialize(value, { maxDepth: 4 }),
|
|
8064
|
+
}),
|
|
8065
|
+
);
|
|
8066
|
+
```
|
|
8067
|
+
|
|
8068
|
+
`serialize: false` sends values as they are, which is faster and can fail on a circular one.
|
|
8069
|
+
|
|
6376
8070
|
### Write your own logger
|
|
6377
8071
|
|
|
6378
8072
|
```ts
|
|
@@ -6402,13 +8096,417 @@ conditions: ["notrace"]
|
|
|
6402
8096
|
|
|
6403
8097
|
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.
|
|
6404
8098
|
|
|
8099
|
+
---
|
|
8100
|
+
Source: docs/debugging-loops.md
|
|
8101
|
+
|
|
8102
|
+
# Find and cut event loops
|
|
8103
|
+
|
|
8104
|
+
> **Level F.** Read first: [Trace tasks](tracing.md#attach-a-tracer). To only cut a loop, go to [`loopGuardPlugin`](loop-guard.md).
|
|
8105
|
+
|
|
8106
|
+
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).
|
|
8107
|
+
|
|
8108
|
+
## On this page
|
|
8109
|
+
|
|
8110
|
+
- [Start from the symptom](#start-from-the-symptom)
|
|
8111
|
+
- [Find a loop](#find-a-loop)
|
|
8112
|
+
- [Read a report](#read-a-report)
|
|
8113
|
+
- [Draw the graph](#draw-the-graph)
|
|
8114
|
+
- [See the loop in Redux DevTools](#see-the-loop-in-redux-devtools)
|
|
8115
|
+
- [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows)
|
|
8116
|
+
- [Break the loop](#break-the-loop)
|
|
8117
|
+
- [Cut a loop at run time](#cut-a-loop-at-run-time)
|
|
8118
|
+
- [Fail a test on a loop](#fail-a-test-on-a-loop)
|
|
8119
|
+
- [Export the graph and the payloads](#export-the-graph-and-the-payloads)
|
|
8120
|
+
- [Know the limits](#know-the-limits)
|
|
8121
|
+
|
|
8122
|
+
## Start from the symptom
|
|
8123
|
+
|
|
8124
|
+
| What you see | Likely cause | Go to |
|
|
8125
|
+
| ------------ | ------------ | ----- |
|
|
8126
|
+
| 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) |
|
|
8127
|
+
| A request repeats on its own | A handler emits the event that starts it | [Read a report](#read-a-report) |
|
|
8128
|
+
| `RangeError: Maximum call stack size exceeded` | A synchronous loop through `emit` | [Find a loop](#find-a-loop) |
|
|
8129
|
+
| 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) |
|
|
8130
|
+
| You do not know which event leads to which | No map of the events | [Draw the graph](#draw-the-graph) |
|
|
8131
|
+
|
|
8132
|
+
## Find a loop
|
|
8133
|
+
|
|
8134
|
+
`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.
|
|
8135
|
+
|
|
8136
|
+
```js
|
|
8137
|
+
// bus.js
|
|
8138
|
+
import { createBus } from "nanoeffects";
|
|
8139
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
8140
|
+
|
|
8141
|
+
// graphLogger — the map of events and the loop finder, in one logger
|
|
8142
|
+
export const graph = graphLogger({ name: "cart" });
|
|
8143
|
+
|
|
8144
|
+
// createTracer — sends every task of the bus to the logger
|
|
8145
|
+
const trace = createTracer(graph);
|
|
8146
|
+
|
|
8147
|
+
// tracePlugin — reports every emit, so the graph also shows events that nothing listens to
|
|
8148
|
+
export const bus = createBus({ trace }).use(tracePlugin());
|
|
8149
|
+
```
|
|
8150
|
+
|
|
8151
|
+
The handlers are the part of your app that has the loop. Here a cart and a total update each other:
|
|
8152
|
+
|
|
8153
|
+
```js
|
|
8154
|
+
// cart-handlers.js
|
|
8155
|
+
import { bus } from "./bus";
|
|
8156
|
+
|
|
8157
|
+
// traceId — the name of the listener in the graph and in the report
|
|
8158
|
+
bus.on(
|
|
8159
|
+
"cart/changed",
|
|
8160
|
+
(cart, ctx) => {
|
|
8161
|
+
ctx.emit("total/changed", cart);
|
|
8162
|
+
},
|
|
8163
|
+
{ traceId: "recalcTotal" },
|
|
8164
|
+
);
|
|
8165
|
+
|
|
8166
|
+
bus.on(
|
|
8167
|
+
"total/changed",
|
|
8168
|
+
(total, ctx) => {
|
|
8169
|
+
ctx.emit("cart/changed", total);
|
|
8170
|
+
},
|
|
8171
|
+
{ traceId: "syncCart" },
|
|
8172
|
+
);
|
|
8173
|
+
```
|
|
8174
|
+
|
|
8175
|
+
`graphLogger` costs a lookup for each task. Use it while you develop, or with `createTracer(graph, { sample })` in production.
|
|
8176
|
+
|
|
8177
|
+
## Read a report
|
|
8178
|
+
|
|
8179
|
+
<!-- docs-check: continue -->
|
|
8180
|
+
|
|
8181
|
+
The report is one `console.warn` for each loop:
|
|
8182
|
+
|
|
8183
|
+
```text
|
|
8184
|
+
[nanoeffects cart] cycle: recalcTotal → total/changed → syncCart → cart/changed → recalcTotal (repeated 3 times, started at cart/changed)
|
|
8185
|
+
recalcTotal at src/cart-handlers.js:6; syncCart at src/cart-handlers.js:14 (only with devPlugin)
|
|
8186
|
+
handlers feed each other: break the loop with a condition, or emit a different event for the result
|
|
8187
|
+
```
|
|
8188
|
+
|
|
8189
|
+
- **The path** goes from the first turn of the loop back to it. A named listener shows its event before it.
|
|
8190
|
+
- **`started at`** is the first event of the chain.
|
|
8191
|
+
- **`at file:line`** appears when you also use [`devPlugin`](hmr.md#report-leaks-with-devplugin).
|
|
8192
|
+
- **The last line** is advice for this shape of loop.
|
|
8193
|
+
|
|
8194
|
+
To receive the finding in code, pass `onCycle`. It replaces the console report:
|
|
8195
|
+
|
|
8196
|
+
```js
|
|
8197
|
+
// report.js
|
|
8198
|
+
import { graphLogger } from "nanoeffects/trace";
|
|
8199
|
+
|
|
8200
|
+
export const graph = graphLogger({
|
|
8201
|
+
// onCycle — called once for each loop; the finding has a path, the repeats and the root
|
|
8202
|
+
onCycle: (finding) => {
|
|
8203
|
+
console.error(finding.kind, finding.path.join(" → "));
|
|
8204
|
+
},
|
|
8205
|
+
});
|
|
8206
|
+
```
|
|
8207
|
+
|
|
8208
|
+
`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"`).
|
|
8209
|
+
|
|
8210
|
+
## Draw the graph
|
|
8211
|
+
|
|
8212
|
+
<!-- docs-check: continue -->
|
|
8213
|
+
|
|
8214
|
+
The graph has a count on each edge, and the edges of a cycle are marked. Write it in the format your tool reads:
|
|
8215
|
+
|
|
8216
|
+
```js
|
|
8217
|
+
// draw.js
|
|
8218
|
+
import { graph } from "./bus";
|
|
8219
|
+
|
|
8220
|
+
// Mermaid: paste it into a README, a pull request or https://mermaid.live
|
|
8221
|
+
const mermaid = graph.toMermaid({ group: "prefix" }); // group the events by the part before "/"
|
|
8222
|
+
|
|
8223
|
+
// Graphviz: dot -Tsvg graph.dot > graph.svg
|
|
8224
|
+
const dot = graph.toDot();
|
|
8225
|
+
|
|
8226
|
+
// plain data for your own tools
|
|
8227
|
+
const data = graph.toJSON();
|
|
8228
|
+
|
|
8229
|
+
console.log(mermaid, dot, data);
|
|
8230
|
+
```
|
|
8231
|
+
|
|
8232
|
+
```mermaid
|
|
8233
|
+
flowchart LR
|
|
8234
|
+
n0["cart/changed"]
|
|
8235
|
+
n1["recalcTotal"]
|
|
8236
|
+
n2["total/changed"]
|
|
8237
|
+
n3["syncCart"]
|
|
8238
|
+
n0 -->|x21| n1
|
|
8239
|
+
n1 -->|x20| n2
|
|
8240
|
+
n2 -->|x20| n3
|
|
8241
|
+
n3 -->|x20| n0
|
|
8242
|
+
linkStyle 0,1,2,3 stroke:red,stroke-width:2px
|
|
8243
|
+
```
|
|
8244
|
+
|
|
8245
|
+
The counts (`x21`) are how many times each edge was seen; they depend on how long the loop ran.
|
|
8246
|
+
|
|
8247
|
+
## See the loop in Redux DevTools
|
|
8248
|
+
|
|
8249
|
+
`devtools: true` sends each finding to the extension as a `cycle/detected` action. Put `reduxDevtools` next to it to also see the emits:
|
|
8250
|
+
|
|
8251
|
+
```js
|
|
8252
|
+
// devtools-bus.js
|
|
8253
|
+
import { createBus } from "nanoeffects";
|
|
8254
|
+
import { createTracer, graphLogger, reduxDevtools, tracePlugin } from "nanoeffects/trace";
|
|
8255
|
+
|
|
8256
|
+
// devtools: true — one cycle/detected action for each loop, with the path
|
|
8257
|
+
const graph = graphLogger({ name: "app", devtools: true });
|
|
8258
|
+
|
|
8259
|
+
// reduxDevtools — the emits of the bus as actions
|
|
8260
|
+
const trace = createTracer([reduxDevtools({ name: "app" }), graph]);
|
|
8261
|
+
|
|
8262
|
+
export const bus = createBus({ trace }).use(tracePlugin());
|
|
8263
|
+
```
|
|
8264
|
+
|
|
8265
|
+
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).
|
|
8266
|
+
|
|
8267
|
+
## Find a loop that no chain shows
|
|
8268
|
+
|
|
8269
|
+
Some loops have no chain of emits:
|
|
8270
|
+
|
|
8271
|
+
- **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`.
|
|
8272
|
+
- **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.
|
|
8273
|
+
- **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).
|
|
8274
|
+
|
|
8275
|
+
```js
|
|
8276
|
+
// storm.js
|
|
8277
|
+
import { graphLogger } from "nanoeffects/trace";
|
|
8278
|
+
|
|
8279
|
+
export const graph = graphLogger({
|
|
8280
|
+
storm: { count: 50, perMs: 1000 }, // 50 emits of one event in a second from outside
|
|
8281
|
+
maxSteps: 2000, // 2000 tasks started by one root
|
|
8282
|
+
ignore: ["tree/visit"], // a recursion on purpose is not counted
|
|
8283
|
+
});
|
|
8284
|
+
```
|
|
8285
|
+
|
|
8286
|
+
## Break the loop
|
|
8287
|
+
|
|
8288
|
+
| Shape | Fix |
|
|
8289
|
+
| ----- | --- |
|
|
8290
|
+
| A handler emits its own event | Emit a different event for the result (`user/loaded`, not `user/load`). |
|
|
8291
|
+
| 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. |
|
|
8292
|
+
| A job whose outcome has the type of its trigger | Give the outcomes their own types. |
|
|
8293
|
+
| A component emits on every render | Pass `equals` to `useEmitter`, so the same value is not an emit, or `debounce` the handler. |
|
|
8294
|
+
| A failure re-emits the event that failed | Stop after a number of tries with `retry`, not with an event. |
|
|
8295
|
+
|
|
8296
|
+
## Cut a loop at run time
|
|
8297
|
+
|
|
8298
|
+
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:
|
|
8299
|
+
|
|
8300
|
+
```js
|
|
8301
|
+
// guarded-bus.js
|
|
8302
|
+
import { createBus } from "nanoeffects";
|
|
8303
|
+
import { loopGuardPlugin } from "nanoeffects/guard";
|
|
8304
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
8305
|
+
|
|
8306
|
+
const graph = graphLogger({ name: "app" });
|
|
8307
|
+
const trace = createTracer(graph);
|
|
8308
|
+
|
|
8309
|
+
// loopGuardPlugin — cuts the loop that the graph reports
|
|
8310
|
+
export const bus = createBus({ trace })
|
|
8311
|
+
.use(tracePlugin())
|
|
8312
|
+
.use(loopGuardPlugin({ maxRepeats: 50 }));
|
|
8313
|
+
```
|
|
8314
|
+
|
|
8315
|
+
The guard is independent of the logger: use either one alone.
|
|
8316
|
+
|
|
8317
|
+
## Fail a test on a loop
|
|
8318
|
+
|
|
8319
|
+
Run a flow of the app and check that the graph has no cycle. A loop that someone adds later fails the build.
|
|
8320
|
+
|
|
8321
|
+
```js
|
|
8322
|
+
// bus.js
|
|
8323
|
+
import { createBus } from "nanoeffects";
|
|
8324
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
8325
|
+
|
|
8326
|
+
// the bus.js of "Find a loop": the graph logger on the tracer of the bus
|
|
8327
|
+
export const graph = graphLogger({ name: "cart" });
|
|
8328
|
+
export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
|
|
8329
|
+
```
|
|
8330
|
+
|
|
8331
|
+
```js
|
|
8332
|
+
// cart-handlers.js
|
|
8333
|
+
import { bus } from "./bus";
|
|
8334
|
+
|
|
8335
|
+
bus.on("checkout/started", (_order, ctx) => {
|
|
8336
|
+
ctx.emit("checkout/paid");
|
|
8337
|
+
});
|
|
8338
|
+
```
|
|
8339
|
+
|
|
8340
|
+
```js
|
|
8341
|
+
// cycles.test.js
|
|
8342
|
+
import { expect, test } from "vitest";
|
|
8343
|
+
import { bus, graph } from "./bus";
|
|
8344
|
+
import "./cart-handlers";
|
|
8345
|
+
|
|
8346
|
+
test("the checkout flow has no event cycles", () => {
|
|
8347
|
+
// run the flow the way a user would
|
|
8348
|
+
bus.emit("checkout/started");
|
|
8349
|
+
|
|
8350
|
+
// cycles() — empty when the graph has no loop
|
|
8351
|
+
expect(graph.cycles()).toEqual([]);
|
|
8352
|
+
});
|
|
8353
|
+
```
|
|
8354
|
+
|
|
8355
|
+
## Export the graph and the payloads
|
|
8356
|
+
|
|
8357
|
+
The exports leave payloads out, because a payload can hold private data. Ask for them when you need them:
|
|
8358
|
+
|
|
8359
|
+
```js
|
|
8360
|
+
// bus.js
|
|
8361
|
+
import { createBus } from "nanoeffects";
|
|
8362
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
8363
|
+
|
|
8364
|
+
// the bus.js of "Find a loop": the graph logger on the tracer of the bus
|
|
8365
|
+
export const graph = graphLogger({ name: "cart" });
|
|
8366
|
+
export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
|
|
8367
|
+
```
|
|
8368
|
+
|
|
8369
|
+
```js
|
|
8370
|
+
// payloads.js
|
|
8371
|
+
import { graph } from "./bus";
|
|
8372
|
+
|
|
8373
|
+
// payload: true — adds the last payload and error of each node, made safe
|
|
8374
|
+
const data = graph.toJSON({ payload: true });
|
|
8375
|
+
|
|
8376
|
+
console.log(JSON.stringify(data));
|
|
8377
|
+
```
|
|
8378
|
+
|
|
8379
|
+
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.
|
|
8380
|
+
|
|
8381
|
+
## Know the limits
|
|
8382
|
+
|
|
8383
|
+
- A chain that repeats on purpose is reported. List its event in `ignore`.
|
|
8384
|
+
- The report is made once for each loop. `turns` in `graph.cycles()` keeps counting.
|
|
8385
|
+
|
|
8386
|
+
---
|
|
8387
|
+
Source: docs/loop-guard.md
|
|
8388
|
+
|
|
8389
|
+
# Cut a loop with `loopGuardPlugin`
|
|
8390
|
+
|
|
8391
|
+
> **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).
|
|
8392
|
+
|
|
8393
|
+
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.
|
|
8394
|
+
|
|
8395
|
+
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.
|
|
8396
|
+
|
|
8397
|
+
## On this page
|
|
8398
|
+
|
|
8399
|
+
- [Cut a loop](#cut-a-loop)
|
|
8400
|
+
- [Choose what happens at the limit](#choose-what-happens-at-the-limit)
|
|
8401
|
+
- [Limit the emits that come from outside](#limit-the-emits-that-come-from-outside)
|
|
8402
|
+
- [Leave a recursion out](#leave-a-recursion-out)
|
|
8403
|
+
- [Know what it does not do](#know-what-it-does-not-do)
|
|
8404
|
+
|
|
8405
|
+
## Cut a loop
|
|
8406
|
+
|
|
8407
|
+
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.
|
|
8408
|
+
|
|
8409
|
+
```js
|
|
8410
|
+
// bus.js
|
|
8411
|
+
import { createBus } from "nanoeffects";
|
|
8412
|
+
import { loopGuardPlugin } from "nanoeffects/guard";
|
|
8413
|
+
|
|
8414
|
+
// loopGuardPlugin — refuses the emit that would pass a limit of a chain
|
|
8415
|
+
export const bus = createBus().use(
|
|
8416
|
+
loopGuardPlugin({
|
|
8417
|
+
maxRepeats: 50, // one event may be in a chain 50 times
|
|
8418
|
+
maxDepth: 1000, // a chain may be 1000 emits long, whatever the names are
|
|
8419
|
+
}),
|
|
8420
|
+
);
|
|
8421
|
+
```
|
|
8422
|
+
|
|
8423
|
+
| Option | Default | Cuts |
|
|
8424
|
+
| ------ | ------- | ---- |
|
|
8425
|
+
| `maxRepeats` | 50 | The same event in one chain too often: a loop. |
|
|
8426
|
+
| `maxDepth` | 1000 | A chain that is too long, also when every event has a new name (`step/${n}`). |
|
|
8427
|
+
| `maxPerSecond` | none | Emits of one event from outside any handler that come too fast ([below](#limit-the-emits-that-come-from-outside)). |
|
|
8428
|
+
| `ignore` | none | Event names that the repeat count skips ([below](#leave-a-recursion-out)). |
|
|
8429
|
+
|
|
8430
|
+
## Choose what happens at the limit
|
|
8431
|
+
|
|
8432
|
+
`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.
|
|
8433
|
+
|
|
8434
|
+
```js
|
|
8435
|
+
// bus.js
|
|
8436
|
+
import { createBus } from "nanoeffects";
|
|
8437
|
+
import { LoopError, loopGuardPlugin } from "nanoeffects/guard";
|
|
8438
|
+
|
|
8439
|
+
export const bus = createBus().use(
|
|
8440
|
+
loopGuardPlugin({
|
|
8441
|
+
onLimit: "drop", // "drop" (default): the emit is not sent; "throw": it throws a LoopError
|
|
8442
|
+
// report — replaces the default console.error
|
|
8443
|
+
report: (finding) => console.error(finding.limit, finding.path.join(" → ")),
|
|
8444
|
+
}),
|
|
8445
|
+
);
|
|
8446
|
+
|
|
8447
|
+
// with onLimit: "throw", the emit that was refused throws, and the error names the path
|
|
8448
|
+
try {
|
|
8449
|
+
bus.emit("cart/changed");
|
|
8450
|
+
} catch (error) {
|
|
8451
|
+
if (error instanceof LoopError) {
|
|
8452
|
+
console.log(error.limit, error.path);
|
|
8453
|
+
}
|
|
8454
|
+
}
|
|
8455
|
+
```
|
|
8456
|
+
|
|
8457
|
+
- **`"drop"`** is the default. The emit returns as if nobody listened, so no new error appears in an async handler.
|
|
8458
|
+
- **`"throw"`** shows the loop with a stack. In an async handler the error becomes a rejection: handle it with `onError`.
|
|
8459
|
+
|
|
8460
|
+
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.
|
|
8461
|
+
|
|
8462
|
+
## Limit the emits that come from outside
|
|
8463
|
+
|
|
8464
|
+
A component that emits on every render has no chain: each emit comes from outside any handler. `maxPerSecond` counts them for each event.
|
|
8465
|
+
|
|
8466
|
+
```js
|
|
8467
|
+
// bus.js
|
|
8468
|
+
import { createBus } from "nanoeffects";
|
|
8469
|
+
import { loopGuardPlugin } from "nanoeffects/guard";
|
|
8470
|
+
|
|
8471
|
+
// maxPerSecond — over 100 emits of one event in a second from outside are refused
|
|
8472
|
+
export const bus = createBus().use(loopGuardPlugin({ maxPerSecond: 100 }));
|
|
8473
|
+
```
|
|
8474
|
+
|
|
8475
|
+
It is off by default, because a burst of many emits can be right (a stream of messages).
|
|
8476
|
+
|
|
8477
|
+
## Leave a recursion out
|
|
8478
|
+
|
|
8479
|
+
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.
|
|
8480
|
+
|
|
8481
|
+
```js
|
|
8482
|
+
// bus.js
|
|
8483
|
+
import { createBus } from "nanoeffects";
|
|
8484
|
+
import { loopGuardPlugin } from "nanoeffects/guard";
|
|
8485
|
+
|
|
8486
|
+
export const bus = createBus().use(
|
|
8487
|
+
loopGuardPlugin({
|
|
8488
|
+
ignore: ["tree/visit"], // names, or a function of the name
|
|
8489
|
+
}),
|
|
8490
|
+
);
|
|
8491
|
+
```
|
|
8492
|
+
|
|
8493
|
+
## Know what it does not do
|
|
8494
|
+
|
|
8495
|
+
- **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.
|
|
8496
|
+
- **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.
|
|
8497
|
+
- **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)).
|
|
8498
|
+
|
|
6405
8499
|
---
|
|
6406
8500
|
Source: docs/observable.md
|
|
6407
8501
|
|
|
6408
8502
|
# Turn events into Observables
|
|
6409
8503
|
|
|
8504
|
+
> **Level F.** Read first: [Guide](guide.md#1-create-a-bus) step 1.
|
|
8505
|
+
|
|
6410
8506
|
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.
|
|
6411
8507
|
|
|
8508
|
+
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).
|
|
8509
|
+
|
|
6412
8510
|
`createObserve(bus)` from `nanoeffects/observable` turns a bus event into an Observable that follows the TC39 proposal (`subscribe`, `unsubscribe`, `Symbol.observable`). RxJS and other stream libraries take it as it is. Nothing here depends on them.
|
|
6413
8511
|
|
|
6414
8512
|
## Turn an event into a stream
|
|
@@ -6552,6 +8650,47 @@ bus.on(
|
|
|
6552
8650
|
|
|
6553
8651
|
`switchMap` drops the old inner stream. `latest` aborts the old task through `signal`, so a `fetch` that got the signal really stops.
|
|
6554
8652
|
|
|
8653
|
+
## Read an event with a `for await` loop
|
|
8654
|
+
|
|
8655
|
+
Problem: a task wants to handle the payloads of one event one after another, in order, and stop when the screen closes. `take` waits for one payload, and a listener has no place for a loop.
|
|
8656
|
+
|
|
8657
|
+
`events(source, event, options)` from `nanoeffects/observable` returns an async iterator of the payloads. It listens from the call, so a payload that comes before the first read is kept. `source` is a bus or a task context; with a context the loop also ends when the task aborts.
|
|
8658
|
+
|
|
8659
|
+
```js
|
|
8660
|
+
import { createBus } from "nanoeffects";
|
|
8661
|
+
import { events } from "nanoeffects/observable";
|
|
8662
|
+
|
|
8663
|
+
const bus = createBus();
|
|
8664
|
+
const controller = new AbortController();
|
|
8665
|
+
|
|
8666
|
+
// events — the "query/changed" payloads one by one; keeps the last 3 while the loop is busy
|
|
8667
|
+
const queries = events(bus, "query/changed", {
|
|
8668
|
+
signal: controller.signal,
|
|
8669
|
+
buffer: 3,
|
|
8670
|
+
});
|
|
8671
|
+
|
|
8672
|
+
async function watchQueries() {
|
|
8673
|
+
for await (const query of queries) {
|
|
8674
|
+
console.log("search for", query); // runs once per payload, in order
|
|
8675
|
+
}
|
|
8676
|
+
}
|
|
8677
|
+
|
|
8678
|
+
watchQueries().catch((reason) => console.log("stopped:", reason)); // the abort rejects the loop
|
|
8679
|
+
bus.emit("query/changed", "cat");
|
|
8680
|
+
controller.abort("screen closed"); // the loop ends with this reason
|
|
8681
|
+
```
|
|
8682
|
+
|
|
8683
|
+
| Option | What it does | Default |
|
|
8684
|
+
| ------ | ------------ | ------- |
|
|
8685
|
+
| `signal` | Ends the loop. The waiting read rejects with the abort reason. | none |
|
|
8686
|
+
| `buffer` | The most payloads kept while the loop is busy. | `Infinity` |
|
|
8687
|
+
| `overflow` | Which payload goes when the buffer is full: `"drop-oldest"` or `"drop-newest"`. | `"drop-oldest"` |
|
|
8688
|
+
|
|
8689
|
+
- `break`, `return` and a throw inside the loop remove the listener.
|
|
8690
|
+
- With no `buffer`, a loop that never reads keeps every payload. Set `buffer` for a slow loop.
|
|
8691
|
+
- An event of a bus does not end, so the loop ends only by `break`, the `signal` or the task.
|
|
8692
|
+
- Inside a task, `events(ctx, "query/changed")` needs no `signal`: the task ending is the end.
|
|
8693
|
+
|
|
6555
8694
|
## Convert back to a stream or a promise
|
|
6556
8695
|
|
|
6557
8696
|
| You need | Write |
|
|
@@ -6599,6 +8738,8 @@ Source: docs/hmr.md
|
|
|
6599
8738
|
|
|
6600
8739
|
# Hot reload and development diagnostics
|
|
6601
8740
|
|
|
8741
|
+
> **Level F.** Read first: [Actions, jobs and modules](actions.md).
|
|
8742
|
+
|
|
6602
8743
|
Use this page in development with modules and hot reload. Skip until you mount domains with `modulePlugin`.
|
|
6603
8744
|
|
|
6604
8745
|
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:
|
|
@@ -6636,11 +8777,43 @@ bus.use(devPlugin());
|
|
|
6636
8777
|
|
|
6637
8778
|
- **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.
|
|
6638
8779
|
- **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.
|
|
6639
|
-
- **
|
|
8780
|
+
- **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.
|
|
8781
|
+
- **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)
|
|
8782
|
+
- **Options.** `abandoned: false`, `leaks: false` and `modules: false` switch a diagnostic off; `maxListeners` sets the limit; `log` receives the messages instead of the console.
|
|
6640
8783
|
- **Disconnect.** `bus.use(devPlugin(...))` returns the function that stops the diagnostics and puts `bus.on` and `app.mount` back.
|
|
6641
8784
|
|
|
6642
8785
|
It changes no behavior: it wraps `bus.on`, `app.mount`, and `app.unmount` and adds messages.
|
|
6643
8786
|
|
|
8787
|
+
### Find a handler that ignores the signal
|
|
8788
|
+
|
|
8789
|
+
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.
|
|
8790
|
+
|
|
8791
|
+
```js
|
|
8792
|
+
import { createBus } from "nanoeffects";
|
|
8793
|
+
import { latest } from "nanoeffects/policies";
|
|
8794
|
+
|
|
8795
|
+
const bus = createBus();
|
|
8796
|
+
|
|
8797
|
+
bus.on(
|
|
8798
|
+
"search",
|
|
8799
|
+
latest(async (query, ctx) => {
|
|
8800
|
+
// the signal is not passed, so an older request is not cancelled
|
|
8801
|
+
const items = await fetch(`/api?q=${query}`).then((response) => response.json());
|
|
8802
|
+
ctx.emit("results", items);
|
|
8803
|
+
}),
|
|
8804
|
+
);
|
|
8805
|
+
```
|
|
8806
|
+
|
|
8807
|
+
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:
|
|
8808
|
+
|
|
8809
|
+
```text
|
|
8810
|
+
[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, ...)).
|
|
8811
|
+
```
|
|
8812
|
+
|
|
8813
|
+
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.
|
|
8814
|
+
|
|
8815
|
+
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.
|
|
8816
|
+
|
|
6644
8817
|
### Keep the import in production
|
|
6645
8818
|
|
|
6646
8819
|
`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.
|