nanoeffects 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +53 -55
  2. package/dist/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
  3. package/dist/actions.d.ts +75 -6
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-BAs9x7IQ.js +1 -0
  6. package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
  7. package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
  8. package/dist/dev.d.ts +2 -2
  9. package/dist/effects.d.ts +153 -56
  10. package/dist/effects.js +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +1 -1
  13. package/dist/{module-I1ixyJzx.d.ts → module-DCJl-qfi.d.ts} +24 -9
  14. package/dist/notrace/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
  15. package/dist/notrace/actions.d.ts +97 -13
  16. package/dist/notrace/actions.js +1 -1
  17. package/dist/notrace/bus-Bdk0YyFQ.js +1 -0
  18. package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
  19. package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
  20. package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
  21. package/dist/notrace/effects.d.ts +153 -56
  22. package/dist/notrace/effects.js +1 -1
  23. package/dist/notrace/index.d.ts +3 -2
  24. package/dist/notrace/index.js +1 -1
  25. package/dist/notrace/policies.d.ts +1 -1
  26. package/dist/notrace/policies.js +1 -1
  27. package/dist/notrace/redux.d.ts +18 -20
  28. package/dist/notrace/redux.js +1 -1
  29. package/dist/observable.d.ts +1 -1
  30. package/dist/policies.d.ts +1 -1
  31. package/dist/policies.js +1 -1
  32. package/dist/react.d.ts +50 -0
  33. package/dist/react.js +1 -0
  34. package/dist/redux.d.ts +18 -20
  35. package/dist/redux.js +1 -1
  36. package/dist/trace.d.ts +1 -1
  37. package/docs/README.md +30 -9
  38. package/docs/actions.md +117 -41
  39. package/docs/context.md +48 -7
  40. package/docs/effects.md +229 -73
  41. package/docs/errors.md +23 -10
  42. package/docs/guide.md +238 -129
  43. package/docs/hmr.md +25 -13
  44. package/docs/migrating-from-effector.md +293 -0
  45. package/docs/migrating-from-rxjs.md +127 -0
  46. package/docs/migrating-from-saga.md +93 -0
  47. package/docs/observable.md +11 -9
  48. package/docs/package.md +24 -0
  49. package/docs/policies.md +93 -30
  50. package/docs/react.md +157 -0
  51. package/docs/semantics.md +5 -3
  52. package/docs/stores.md +488 -25
  53. package/docs/tracing.md +69 -54
  54. package/docs/typescript.md +153 -0
  55. package/llms-full.txt +2645 -0
  56. package/llms.txt +48 -0
  57. package/package.json +39 -9
  58. package/dist/bus-CRnlwmQ4.js +0 -1
  59. package/dist/notrace/bus-Dx0l9X6h.js +0 -1
  60. package/dist/notrace/event-0RIBGYqS.js +0 -1
package/docs/policies.md CHANGED
@@ -1,41 +1,93 @@
1
1
  # Policies
2
2
 
3
- A policy wraps a handler and decides what happens when calls overlap. Every policy takes `key` in its options (see below), so each key has its own state.
3
+ Use this page when handlers overlap in a SPA (search-as-you-type, double submit, leave page). A **policy** wraps a handler and decides what happens then.
4
4
 
5
- | Policy | Overlapping calls |
6
- | ------------------------------ | -------------------------------------------------------------------------- |
7
- | `latest(fn)` | the previous run is aborted and its result dropped |
8
- | `leading(fn)` | new calls are dropped while a run is going |
9
- | `debounce(ms, fn, options?)` | run after `ms` of quiet |
10
- | `throttle(ms, fn, options?)` | at most one run per `ms` |
11
- | `queue(fn, options?)` | one after another, in order |
5
+ Start with `latest`. The table and sections below are the rest of the reference.
12
6
 
13
- `debounce` and `throttle` take `leading` and `trailing`:
7
+ ## Abort the previous run
14
8
 
15
- - `throttle(ms, fn)` works like lodash `throttle` (and patronum): a call made when the throttle is idle, at least `ms` after the previous call and the previous run, runs at once (`leading`, default `true`); calls made while it waits are held, and the latest of them runs when the window closes (`trailing`, default `true`). Under a steady stream of calls it runs every `ms`. `leading: false` makes even the first call wait for the end of its window; `trailing: false` drops the calls made during a window.
16
- - `debounce(ms, fn)` runs the last call after `ms` of quiet (`trailing`, default `true`). `leading: true` also runs the first call of a burst at once, and then the trailing run happens only if more calls follow it. `leading: true, trailing: false` runs only the first call of a burst.
17
- - With both options `false` nothing runs.
18
- - `throttle` and `debounce` are checked against lodash itself: the tests feed random sequences of calls to both and compare the moments `fn` runs.
9
+ ```js
10
+ import { createBus } from "nanoeffects";
11
+ import { latest } from "nanoeffects/policies";
19
12
 
20
- `key` gives each key its own state, so calls with different keys do not affect each other:
13
+ const bus = createBus();
21
14
 
22
- ```ts
23
- // one latest run per user instead of one for all of them
24
- bus.on("load", latest(loadUser, { key: (user) => user.id }));
25
-
26
- // uploads of the same file go one after another, different files in parallel
27
- bus.on("upload", queue(upload, { key: (file) => file.name }));
15
+ // latest — a newer run aborts the older one
16
+ bus.on(
17
+ "search",
18
+ latest(async (query, { signal }) => {
19
+ // signal — lets latest cancel this request, not only drop its result
20
+ await api.search(query, { signal });
21
+ }),
22
+ );
28
23
  ```
29
24
 
30
- The state of a key is dropped when that key goes idle, so keys can be unbounded (ids, urls).
25
+ Pass `signal` to `fetch` (and the like) so the request cancels.
26
+
27
+ ## Choose a policy
28
+
29
+ Every policy accepts `key` in its options (see [Isolate state by key](#isolate-state-by-key)).
30
+
31
+ | Policy | Overlapping calls |
32
+ | ------ | ----------------- |
33
+ | `latest(fn)` | previous run aborted, result dropped |
34
+ | `leading(fn)` | new calls dropped while a run is going |
35
+ | `debounce(ms, fn, options?)` | run after `ms` of quiet |
36
+ | `throttle(ms, fn, options?)` | at most one run per `ms` |
37
+ | `queue(fn, options?)` | one after another, in order |
38
+
39
+ ## Configure debounce and throttle
40
+
41
+ `debounce` and `throttle` take `leading` and `trailing`. Behavior matches lodash (and patronum); tests compare random call sequences to lodash itself.
42
+
43
+ **Throttle** (`throttle(ms, fn)`):
44
+
45
+ - Default `leading: true`, `trailing: true`.
46
+ - A call when the throttle is idle (at least `ms` after the previous call and run) runs at once.
47
+ - Calls made while it waits are held; the latest runs when the window closes.
48
+ - Under a steady stream it runs every `ms`.
49
+ - `leading: false` — even the first call waits for the end of its window.
50
+ - `trailing: false` — drop calls made during a window.
51
+
52
+ **Debounce** (`debounce(ms, fn)`):
53
+
54
+ - Default `trailing: true` — run the last call after `ms` of quiet.
55
+ - `leading: true` — also run the first call of a burst at once; the trailing run happens only if more calls follow.
56
+ - `leading: true, trailing: false` — only the first call of a burst.
57
+ - Both `false` — nothing runs.
58
+
59
+ ## Isolate state by key
31
60
 
32
- ## Queue
61
+ `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).
33
62
 
34
- `queue(fn, options?)` runs the calls of a handler one after another, in the order they arrived, instead of dropping or replacing them like `leading` and `latest` do:
63
+ ```js
64
+ import { createBus } from "nanoeffects";
65
+ import { latest, queue } from "nanoeffects/policies";
35
66
 
36
- ```ts
67
+ const bus = createBus();
68
+
69
+ // one latest run per user id
70
+ bus.on(
71
+ "load",
72
+ latest(loadUser, { key: (user) => user.id }),
73
+ );
74
+
75
+ // uploads of the same file go one after another
76
+ bus.on(
77
+ "upload",
78
+ queue(upload, { key: (file) => file.name }),
79
+ );
80
+ ```
81
+
82
+ ## Run calls one after another with `queue`
83
+
84
+ ```js
85
+ import { createBus } from "nanoeffects";
37
86
  import { queue } from "nanoeffects/policies";
38
87
 
88
+ const bus = createBus();
89
+
90
+ // queue — one after another, in order
39
91
  bus.on(
40
92
  "save",
41
93
  queue(async (draft, { signal }) => {
@@ -43,11 +95,22 @@ bus.on(
43
95
  }),
44
96
  );
45
97
 
46
- bus.emit("save", a); // runs now
47
- bus.emit("save", b); // waits for a, then runs
98
+ bus.emit("save", draftA); // runs now
99
+ bus.emit("save", draftB); // waits for draftA, then runs
48
100
  ```
49
101
 
50
- - `concurrency` (default 1) is how many calls may run at the same time.
51
- - `limit` caps how many calls may wait, and `overflow` says what to drop when the line is full: the new call (`"drop-new"`, the default) or the oldest waiting one (`"drop-old"`). A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop-old" })` keeps only the latest call while one is running and runs it when the current one is done; `limit: 0` lets nothing wait.
52
- - Every call returns a promise with the result of its own run. An error rejects only that call and the queue goes on.
53
- - A waiting call whose task aborts is skipped and resolves with `undefined`. When the task of a running call aborts, its slot is freed at once and its result is dropped, so pass `signal` to what `fn` does: a `fn` that ignores it can overlap with the next run.
102
+ | Option | Meaning |
103
+ | ------ | ------- |
104
+ | `concurrency` | how many calls may run at once (default `1`) |
105
+ | `limit` | how many calls may wait |
106
+ | `overflow` | when the line is full: `"drop-new"` (default) or `"drop-old"` |
107
+
108
+ A dropped call resolves with `undefined`. `queue(fn, { limit: 1, overflow: "drop-old" })` keeps only the latest waiting call. `limit: 0` lets nothing wait.
109
+
110
+ Every call returns a promise with the result of its own run. An error rejects only that call; the queue continues.
111
+
112
+ **Abort:**
113
+
114
+ - A waiting call whose task aborts is skipped and resolves with `undefined`.
115
+ - When a running call aborts, its slot is freed at once and its result is dropped.
116
+ - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
package/docs/react.md ADDED
@@ -0,0 +1,157 @@
1
+ # Build a sign-up form in React
2
+
3
+ Use this page when a React screen starts requests on user input and you need them not to race: a username check while typing, a submit button, a flaky network. It builds one registration form. The bus lives outside React; the component only subscribes to it.
4
+
5
+ You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
6
+
7
+ ## See what goes wrong without a bus
8
+
9
+ A registration form has three problems that `useEffect` and `fetch` leave to you:
10
+
11
+ - **A stale answer.** The user types `ann`, then `anna`. The answer for `ann` arrives last and says the name is free, although `anna` is taken.
12
+ - **A double submit.** A second click on "Sign up" sends a second request while the first one is running.
13
+ - **A flaky network.** One failed request should be retried, but a "name is taken" answer should not.
14
+
15
+ Each has a policy or an effect: `latest`, `leading`, `retry`. They live in one place, outside the component.
16
+
17
+ ## Wire the bus once
18
+
19
+ Put the bus and its handlers in their own file. Create it once, outside any component: a bus created in the body of a component would be a new bus, without subscribers, on every render.
20
+
21
+ ```js
22
+ // registration.js
23
+ import { createBus } from "nanoeffects";
24
+ import { retry } from "nanoeffects/effects";
25
+ import { debounce, latest, leading } from "nanoeffects/policies";
26
+
27
+ // fetch rejects with a TypeError when the network fails; an HTTP error is a plain Error
28
+ const isNetworkError = (error) => error instanceof TypeError;
29
+
30
+ export const bus = createBus(); // the bus: created once, at the top of the module
31
+
32
+ // debounce — wait 300 ms of quiet; latest — abort the check of the previous name
33
+ bus.on(
34
+ "username/typed",
35
+ debounce(
36
+ 300,
37
+ latest(async (name, ctx) => {
38
+ // api — your client; pass ctx.signal so latest can cancel the request itself
39
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
40
+ ctx.emit("username/checked", { name, available });
41
+ }),
42
+ ),
43
+ );
44
+
45
+ // leading — a second click is ignored while the first sign up is running
46
+ bus.on(
47
+ "form/submitted",
48
+ leading(async (form, ctx) => {
49
+ const signUp = () => api.signUp(form, { signal: ctx.signal });
50
+ try {
51
+ // retry — up to 3 tries, 500 ms apart, and only when the network failed
52
+ const user = await retry(ctx, signUp, {
53
+ times: 3,
54
+ delay: 500,
55
+ shouldRetry: isNetworkError,
56
+ });
57
+ ctx.emit("form/succeeded", user);
58
+ } catch (error) {
59
+ ctx.emit("form/failed", error.message);
60
+ }
61
+ }),
62
+ );
63
+ ```
64
+
65
+ 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`.
66
+
67
+ ## Connect the form
68
+
69
+ 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.
70
+
71
+ ```jsx
72
+ // SignUpForm.jsx
73
+ import { useEffect, useState } from "react";
74
+ import { bus } from "./registration"; // the bus of the previous snippet
75
+
76
+ export function SignUpForm() {
77
+ const [name, setName] = useState("");
78
+ const [password, setPassword] = useState("");
79
+ const [available, setAvailable] = useState(null); // null: not checked yet
80
+ const [status, setStatus] = useState("idle"); // idle, sending, done or failed
81
+ const [error, setError] = useState("");
82
+
83
+ useEffect(() => {
84
+ // bus.on — listen for what the handlers report; each call returns an unsubscribe function
85
+ const unbindChecked = bus.on("username/checked", (result) => {
86
+ setAvailable(result.available);
87
+ });
88
+ const unbindSucceeded = bus.on("form/succeeded", () => setStatus("done"));
89
+ const unbindFailed = bus.on("form/failed", (message) => {
90
+ setStatus("failed");
91
+ setError(message);
92
+ });
93
+
94
+ return () => {
95
+ unbindChecked();
96
+ unbindSucceeded();
97
+ unbindFailed();
98
+ };
99
+ }, []);
100
+
101
+ function handleName(event) {
102
+ const value = event.target.value;
103
+ setName(value);
104
+ setAvailable(null);
105
+ bus.emit("username/typed", value); // bus.emit — the handler debounces and checks the name
106
+ }
107
+
108
+ function handleSubmit(event) {
109
+ event.preventDefault();
110
+ setStatus("sending");
111
+ bus.emit("form/submitted", { name, password }); // a second click is dropped by leading
112
+ }
113
+
114
+ return (
115
+ <form onSubmit={handleSubmit}>
116
+ <input value={name} onChange={handleName} placeholder="Username" />
117
+ {available === false && <p>This name is taken</p>}
118
+ <input
119
+ type="password"
120
+ value={password}
121
+ onChange={(event) => setPassword(event.target.value)}
122
+ />
123
+ <button type="submit" disabled={status === "sending"}>Sign up</button>
124
+ {status === "failed" && <p>{error}</p>}
125
+ {status === "done" && <p>Welcome!</p>}
126
+ </form>
127
+ );
128
+ }
129
+ ```
130
+
131
+ The `disabled` button is only for the look. The form stays correct without it: `leading` drops a second submit while the first one runs.
132
+
133
+ In development, React StrictMode runs the effect twice (subscribe, unsubscribe, subscribe). That is safe here, because the cleanup removes exactly what the effect added.
134
+
135
+ ## Stop running work when the form closes
136
+
137
+ 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`:
138
+
139
+ ```js
140
+ import { latest } from "nanoeffects/policies";
141
+
142
+ // the username check of registration.js, with one new line
143
+ const checkName = latest(async (name, ctx) => {
144
+ // ctx.on — a subscription of this task; aborting the task also drops the result
145
+ ctx.on("form/closed", () => ctx.abort());
146
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
147
+ ctx.emit("username/checked", { name, available });
148
+ });
149
+ ```
150
+
151
+ In the component, add `bus.emit("form/closed")` to the cleanup function of the effect. This is optional: skip it when finishing a request after unmount does no harm, as for a cheap check.
152
+
153
+ ## Where to go next
154
+
155
+ - The policies behind this form: [Policies](policies.md).
156
+ - `retry`, `timeout` and the other effects: [Effects](effects.md).
157
+ - A state library instead of `useState`: [Stores](stores.md).
package/docs/semantics.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # Semantics
2
2
 
3
- - **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.
4
- - **`emit` is a plain, synchronous loop** (see "Errors in handlers"). 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.
3
+ Guarantees and sharp edges. Read when something surprises you; not required to start.
4
+
5
+ - **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.
6
+ - **`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.
5
7
  - **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.
6
- - **`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, so pass `ctx.signal` to what `fn` does.
8
+ - **`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.
7
9
  - **Effects start their function synchronously**, so an event emitted right after the call reaches a `take` inside it.