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.
- package/README.md +53 -55
- package/dist/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
- package/dist/actions.d.ts +75 -6
- package/dist/actions.js +1 -1
- package/dist/bus-BAs9x7IQ.js +1 -0
- package/dist/{context-DlA80GOA.js → context-CK0ZomWt.js} +1 -1
- package/dist/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +153 -56
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/{module-I1ixyJzx.d.ts → module-DCJl-qfi.d.ts} +24 -9
- package/dist/notrace/{action-Bdfp-Wvd.d.ts → action-creator-DOWcbXpy.d.ts} +79 -73
- package/dist/notrace/actions.d.ts +97 -13
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/bus-Bdk0YyFQ.js +1 -0
- package/dist/notrace/{context-yAWj0BFZ.js → context-CctVmHLC.js} +1 -1
- package/dist/notrace/context-plugin-B0sLWOxR.d.ts +34 -0
- package/dist/notrace/{delay-Cgvn16oY.js → delay-B_W1tnkS.js} +1 -1
- package/dist/notrace/effects.d.ts +153 -56
- package/dist/notrace/effects.js +1 -1
- package/dist/notrace/index.d.ts +3 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +18 -20
- package/dist/notrace/redux.js +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/policies.d.ts +1 -1
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +50 -0
- package/dist/react.js +1 -0
- package/dist/redux.d.ts +18 -20
- package/dist/redux.js +1 -1
- package/dist/trace.d.ts +1 -1
- package/docs/README.md +30 -9
- package/docs/actions.md +117 -41
- package/docs/context.md +48 -7
- package/docs/effects.md +229 -73
- package/docs/errors.md +23 -10
- package/docs/guide.md +238 -129
- package/docs/hmr.md +25 -13
- package/docs/migrating-from-effector.md +293 -0
- package/docs/migrating-from-rxjs.md +127 -0
- package/docs/migrating-from-saga.md +93 -0
- package/docs/observable.md +11 -9
- package/docs/package.md +24 -0
- package/docs/policies.md +93 -30
- package/docs/react.md +157 -0
- package/docs/semantics.md +5 -3
- package/docs/stores.md +488 -25
- package/docs/tracing.md +69 -54
- package/docs/typescript.md +153 -0
- package/llms-full.txt +2645 -0
- package/llms.txt +48 -0
- package/package.json +39 -9
- package/dist/bus-CRnlwmQ4.js +0 -1
- package/dist/notrace/bus-Dx0l9X6h.js +0 -1
- package/dist/notrace/event-0RIBGYqS.js +0 -1
package/docs/policies.md
CHANGED
|
@@ -1,41 +1,93 @@
|
|
|
1
1
|
# Policies
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
## Abort the previous run
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
13
|
+
const bus = createBus();
|
|
21
14
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
```js
|
|
64
|
+
import { createBus } from "nanoeffects";
|
|
65
|
+
import { latest, queue } from "nanoeffects/policies";
|
|
35
66
|
|
|
36
|
-
|
|
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",
|
|
47
|
-
bus.emit("save",
|
|
98
|
+
bus.emit("save", draftA); // runs now
|
|
99
|
+
bus.emit("save", draftB); // waits for draftA, then runs
|
|
48
100
|
```
|
|
49
101
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
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.
|