nanoeffects 0.34.0 → 0.35.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/dist/signal.d.ts +11 -10
- package/dist/signal.js +1 -1
- package/docs/README.md +29 -2
- package/docs/cancel-stale-requests.md +1 -1
- package/docs/context.md +1 -1
- package/docs/package.md +1 -1
- package/docs/pass-the-signal.md +45 -17
- package/docs/policies.md +1 -1
- package/docs/react.md +41 -0
- package/docs/ru/README.md +29 -2
- package/docs/ru/cancel-stale-requests.md +1 -1
- package/docs/ru/context.md +1 -1
- package/docs/ru/package.md +1 -1
- package/docs/ru/pass-the-signal.md +46 -18
- package/docs/ru/policies.md +1 -1
- package/docs/ru/react.md +41 -0
- package/docs/ru/svelte.md +41 -0
- package/docs/ru/vue.md +45 -0
- package/docs/svelte.md +41 -0
- package/docs/vue.md +45 -0
- package/llms-full.txt +176 -21
- package/package.json +2 -2
package/dist/signal.d.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import { A as HandledCall, C as Emit, E as EventLike, H as EventMap, s as EmitScope, u as WithScope, v as Bus } from "./action-creator-DprJV-pN.js";
|
|
1
|
+
import { A as HandledCall, C as Emit, E as EventLike, H as EventMap, I as WithSignal, s as EmitScope, u as WithScope, v as Bus } from "./action-creator-DprJV-pN.js";
|
|
2
2
|
import { n as FromSignal } from "./context-types-BiTMp_lW.js";
|
|
3
3
|
//#region src/signal/fetch.d.ts
|
|
4
4
|
/**
|
|
5
|
-
* A `fetch` that
|
|
6
|
-
*
|
|
7
|
-
* `signal` of
|
|
5
|
+
* A `fetch` that stops with its task: an effect, so `ctx` is the first argument and the request carries
|
|
6
|
+
* `ctx.signal`. The caller never passes the signal. A call that has a `signal` of its own in `init` stops with
|
|
7
|
+
* whichever aborts first. Outside a task pass `{ signal }` of the source, or `{}` in plain JavaScript.
|
|
8
|
+
*
|
|
9
|
+
* With `effectsPlugin({ fetch: signalFetch })` every task has a `ctx.fetch(input, init)` bound to itself.
|
|
8
10
|
*
|
|
9
11
|
* @example
|
|
10
12
|
* ```ts
|
|
11
|
-
* const bus = createBus().use(
|
|
12
|
-
* contextPlugin({ fetch: fromSignal(signalFetch) }),
|
|
13
|
-
* );
|
|
14
|
-
*
|
|
15
13
|
* bus.on("search", latest(async (query, ctx) => {
|
|
16
|
-
* const response = await ctx
|
|
14
|
+
* const response = await signalFetch(ctx, `/api?q=${query}`); // stops when the policy cancels the handler
|
|
17
15
|
* ctx.emit("results", await response.json());
|
|
18
16
|
* }));
|
|
17
|
+
*
|
|
18
|
+
* const withFetch = createBus().use(effectsPlugin({ fetch: signalFetch }));
|
|
19
|
+
* withFetch.on("search", latest(async (query, { fetch }) => fetch(`/api?q=${query}`)));
|
|
19
20
|
* ```
|
|
20
21
|
*/
|
|
21
|
-
export declare const signalFetch: (
|
|
22
|
+
export declare const signalFetch: (ctx: WithSignal, input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
22
23
|
//#endregion
|
|
23
24
|
//#region src/signal/from-signal.d.ts
|
|
24
25
|
/**
|
package/dist/signal.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{i as e}from"./caller-DULg_ZYV.js";import{t}from"./onAbort-BgMfspK4.js";import{n}from"./promise-CSLpzwSR.js";import{n as r}from"./bus-Cmk4U1hQ.js";import{t as i}from"./signal-field-key-CWT-3OXC.js";const a=e
|
|
1
|
+
import{i as e}from"./caller-DULg_ZYV.js";import{t}from"./onAbort-BgMfspK4.js";import{n}from"./promise-CSLpzwSR.js";import{n as r}from"./bus-Cmk4U1hQ.js";import{t as i}from"./signal-field-key-CWT-3OXC.js";const a=(e,t,n)=>{let r=n?.signal,i=r&&e.signal?AbortSignal.any([e.signal,r]):r??e.signal;return fetch(t,{...n,signal:i})},o=e=>({[i]:e}),s=(n,r)=>{let i=(e,i,a)=>{let o=a?.signal;if(!o)return n(e,i,a);let s=n(e,r?(e,t)=>(c(),i(e,t)):i,a),c=t({signal:o},s);return()=>{c(),s()}};return(t,n,r)=>e(()=>i(t,n,r))},c=e=>typeof e==`object`&&!!e,l=(e,t)=>{let n=t[0];if(!c(n)||!c(e)&&typeof e!=`function`)return;let r=Reflect.get(n,`signal`);return r instanceof AbortSignal?r:void 0},u=(e,i,a,o)=>{let s=t({signal:e},e=>{for(let{target:t}of i)t.abort(e)});return n(o)?o.then(s,s):r(a)&&s(),o},d=()=>e=>{let t,n,r=e.addContext;e.addContext=e=>{r?.(e),t?.push({target:e}),n?.aborted&&e.abort(n.reason)};let i=e.emit;e.emit=(e,...r)=>{let a=l(e,r);if(!(a||t))return i(e,...r);let o=t,s=n,c=[];t=a?c:void 0,n=a;let d;try{d=i(e,...r)}finally{t=o,n=s}return a?u(a,c,e,d):d};let a=e.on,o=e.once;return e.on=s(a,!1),e.once=s(o,!0),e};export{o as fromSignal,a as signalFetch,d as signalPlugin};
|
package/docs/README.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
nanoeffects moves business logic and side effects out of UI components into plain `async` functions. Policies (`latest`, `debounce`, `queue`) and effects (`retry`, `timeout`, `poll`) handle races, retries and cancellation.
|
|
4
4
|
|
|
5
5
|
```js
|
|
6
|
+
// search.js
|
|
6
7
|
import { createBus } from "nanoeffects";
|
|
7
8
|
import { latest } from "nanoeffects/policies";
|
|
8
9
|
|
|
9
|
-
const bus = createBus(); // event bus
|
|
10
|
+
export const bus = createBus(); // event bus
|
|
10
11
|
|
|
11
12
|
// The search logic lives outside the component; latest aborts the previous request
|
|
12
13
|
bus.on(
|
|
@@ -22,7 +23,33 @@ bus.on("found", (items) => render(items));
|
|
|
22
23
|
bus.emit("search", "nano");
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
In a component, the same bus needs no extra code. This is React; the handler stays in `search.js`:
|
|
27
|
+
|
|
28
|
+
```jsx
|
|
29
|
+
// SearchBox.jsx
|
|
30
|
+
import { useEffect, useState } from "react";
|
|
31
|
+
import { bus } from "./search";
|
|
32
|
+
|
|
33
|
+
export function SearchBox() {
|
|
34
|
+
const [items, setItems] = useState([]);
|
|
35
|
+
|
|
36
|
+
// on returns the unbind function; returning it removes the listener on unmount
|
|
37
|
+
useEffect(() => bus.on("found", setItems), []);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<input onChange={(event) => bus.emit("search", event.target.value)} />
|
|
42
|
+
<ul>
|
|
43
|
+
{items.map((item) => (
|
|
44
|
+
<li key={item.id}>{item.title}</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The same example step by step: [React](react.md), [Vue](vue.md), [Svelte](svelte.md). Ready projects: [Start a project from a template](starter.md).
|
|
26
53
|
|
|
27
54
|
## Install
|
|
28
55
|
|
|
@@ -47,7 +47,7 @@ bus.on("results", (items) => render(items));
|
|
|
47
47
|
|
|
48
48
|
What to replace: `api.search` is your own request function. It must accept `{ signal }` and give it to `fetch`. Without it, `latest` still ignores the old result, but the request keeps running.
|
|
49
49
|
|
|
50
|
-
To make the mistake impossible, bind the signal once: `
|
|
50
|
+
To make the mistake impossible, bind the signal once: `signalFetch(ctx, url)` is a `fetch` that stops with the handler, and `effectsPlugin({ fetch: signalFetch })` gives every handler a `ctx.fetch` of it ([how](pass-the-signal.md#bind-the-signal-once)). To find the requests that already forget it, use `devPlugin` while you develop, and `getLostSignalsCount(bus)` in a test ([how](pass-the-signal.md#count-lost-signals-in-a-test)).
|
|
51
51
|
|
|
52
52
|
Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
|
|
53
53
|
|
package/docs/context.md
CHANGED
|
@@ -99,7 +99,7 @@ bus.on("user/open", async (id, ctx) => {
|
|
|
99
99
|
});
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
- `signalFetch` from the same entry
|
|
102
|
+
- For `fetch` you need no `fromSignal`: `signalFetch(ctx, url)` from the same entry stops with the task, and `effectsPlugin({ fetch: signalFetch })` gives every handler a `ctx.fetch`. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
|
|
103
103
|
- `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
|
|
104
104
|
- A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
|
|
105
105
|
- The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
|
package/docs/package.md
CHANGED
|
@@ -21,7 +21,7 @@ Each row is optional. The "when" column says what need brings you to it.
|
|
|
21
21
|
| `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
|
|
22
22
|
| `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
|
|
23
23
|
| `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 |
|
|
24
|
-
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal`; `
|
|
24
|
+
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal`; `signalFetch(ctx, url)`: a `fetch` that stops with its task; `fromSignal`: a client made from the signal of each task | an outside `AbortSignal` (request, React Query) must stop tasks, or a handler must not pass the signal by hand |
|
|
25
25
|
| `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 |
|
|
26
26
|
| `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
|
|
27
27
|
| `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
|
package/docs/pass-the-signal.md
CHANGED
|
@@ -22,25 +22,36 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
|
|
|
22
22
|
|
|
23
23
|
## Bind the signal once
|
|
24
24
|
|
|
25
|
-
The surest fix is a handler
|
|
25
|
+
The surest fix is a handler that never writes a signal. `signalFetch` is a `fetch` that takes `ctx` first, like `retry(ctx, ...)`: the request carries `ctx.signal`, so there is nothing to forget.
|
|
26
26
|
|
|
27
27
|
```js
|
|
28
|
-
//
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import axios from "axios";
|
|
28
|
+
// search-handlers.js
|
|
29
|
+
import { latest } from "nanoeffects/policies";
|
|
30
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
31
|
+
import { bus } from "./bus";
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
fetch:
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
bus.on(
|
|
34
|
+
"search",
|
|
35
|
+
latest(async (query, ctx) => {
|
|
36
|
+
// signalFetch — fetch with ctx.signal: it stops when the policy cancels the handler
|
|
37
|
+
const response = await signalFetch(ctx, `/api?q=${query}`);
|
|
38
|
+
ctx.emit("results", await response.json());
|
|
40
39
|
}),
|
|
41
40
|
);
|
|
42
41
|
```
|
|
43
42
|
|
|
43
|
+
To get `ctx.fetch` in every handler, put the effect on the context once. `effectsPlugin` binds it to each task, so the handler drops the `ctx` argument:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
// bus.js
|
|
47
|
+
import { createBus } from "nanoeffects";
|
|
48
|
+
import { effectsPlugin } from "nanoeffects/effects";
|
|
49
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
50
|
+
|
|
51
|
+
// effectsPlugin — ctx.fetch is signalFetch bound to the task that reads it
|
|
52
|
+
export const bus = createBus().use(effectsPlugin({ fetch: signalFetch }));
|
|
53
|
+
```
|
|
54
|
+
|
|
44
55
|
```js
|
|
45
56
|
// search-handlers.js
|
|
46
57
|
import { latest } from "nanoeffects/policies";
|
|
@@ -48,10 +59,27 @@ import { bus } from "./bus";
|
|
|
48
59
|
|
|
49
60
|
bus.on(
|
|
50
61
|
"search",
|
|
51
|
-
latest(async (query,
|
|
52
|
-
// no signal
|
|
53
|
-
const response = await
|
|
54
|
-
|
|
62
|
+
latest(async (query, { fetch, emit }) => {
|
|
63
|
+
// no signal and no ctx argument: fetch stops when the policy cancels the handler
|
|
64
|
+
const response = await fetch(`/api?q=${query}`);
|
|
65
|
+
emit("results", await response.json());
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A client that is not an effect, such as an `axios` instance, is made from the signal of each task with `fromSignal`:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
// bus.js
|
|
74
|
+
import { createBus } from "nanoeffects";
|
|
75
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
76
|
+
import { fromSignal } from "nanoeffects/signal";
|
|
77
|
+
import axios from "axios";
|
|
78
|
+
|
|
79
|
+
// fromSignal — ctx.http is made from the signal of the task that reads it
|
|
80
|
+
export const bus = createBus().use(
|
|
81
|
+
contextPlugin({
|
|
82
|
+
http: fromSignal((signal) => axios.create({ signal })),
|
|
55
83
|
}),
|
|
56
84
|
);
|
|
57
85
|
```
|
|
@@ -123,7 +151,7 @@ Each row is a situation where the signal is easy to lose. The right column says
|
|
|
123
151
|
| 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 | |
|
|
124
152
|
| The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
|
|
125
153
|
| `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
|
|
126
|
-
| A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx
|
|
154
|
+
| A request that keeps running after the cancel, on Node | Pass the signal, or use `signalFetch(ctx, url)` | | yes |
|
|
127
155
|
| Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
|
|
128
156
|
| `ctx.on` after the cancel | The same | | yes |
|
|
129
157
|
|
package/docs/policies.md
CHANGED
|
@@ -24,7 +24,7 @@ bus.on(
|
|
|
24
24
|
);
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Pass `signal` to `fetch` (and the like) so the request cancels. To not pass it at all, bind it once: with `ctx
|
|
27
|
+
Pass `signal` to `fetch` (and the like) so the request cancels. To not pass it at all, bind it once: with `signalFetch(ctx, url)` the request stops with the handler and there is nothing to forget ([Keep the signal through a handler](pass-the-signal.md#bind-the-signal-once)).
|
|
28
28
|
|
|
29
29
|
## Choose a policy
|
|
30
30
|
|
package/docs/react.md
CHANGED
|
@@ -8,6 +8,47 @@ Steps 1 and 2 are required, step 3 is not. The last two sections are extra.
|
|
|
8
8
|
|
|
9
9
|
You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
|
|
10
10
|
|
|
11
|
+
The shortest version: a name field that asks the backend whether the name is free. The rest of the page builds a whole form from it.
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
// username.js
|
|
15
|
+
import { createBus } from "nanoeffects";
|
|
16
|
+
import { latest } from "nanoeffects/policies";
|
|
17
|
+
|
|
18
|
+
export const bus = createBus(); // the bus, created once
|
|
19
|
+
|
|
20
|
+
// the name check, outside the component; latest aborts the check of the previous name
|
|
21
|
+
bus.on(
|
|
22
|
+
"username/typed",
|
|
23
|
+
latest(async (name, ctx) => {
|
|
24
|
+
const available = await api.usernameAvailable(name, { signal: ctx.signal });
|
|
25
|
+
ctx.emit("username/checked", available);
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```jsx
|
|
31
|
+
// NameField.jsx
|
|
32
|
+
import { useEffect, useState } from "react";
|
|
33
|
+
import { bus } from "./username";
|
|
34
|
+
|
|
35
|
+
export function NameField() {
|
|
36
|
+
const [available, setAvailable] = useState(null); // null: not checked yet
|
|
37
|
+
|
|
38
|
+
// on returns the unbind function; returning it removes the listener on unmount
|
|
39
|
+
useEffect(() => bus.on("username/checked", setAvailable), []);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<input onChange={(event) => bus.emit("username/typed", event.target.value)} />
|
|
44
|
+
{available === false && <p>This name is taken</p>}
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The handler knows nothing about React, and the component knows nothing about requests.
|
|
51
|
+
|
|
11
52
|
## On this page
|
|
12
53
|
|
|
13
54
|
- [Problems of the form without a bus](#problems-of-the-form-without-a-bus)
|
package/docs/ru/README.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
nanoeffects выносит бизнес-логику и побочные эффекты из компонентов в обычные `async`-функции. Политики (`latest`, `debounce`, `queue`) и эффекты (`retry`, `timeout`, `poll`) решают гонки, повторы и отмену запросов.
|
|
4
4
|
|
|
5
5
|
```js
|
|
6
|
+
// search.js
|
|
6
7
|
import { createBus } from "nanoeffects";
|
|
7
8
|
import { latest } from "nanoeffects/policies";
|
|
8
9
|
|
|
9
|
-
const bus = createBus(); // шина событий
|
|
10
|
+
export const bus = createBus(); // шина событий
|
|
10
11
|
|
|
11
12
|
// Логика поиска живёт вне компонента; latest прерывает предыдущий запрос
|
|
12
13
|
bus.on(
|
|
@@ -22,7 +23,33 @@ bus.on("found", (items) => render(items));
|
|
|
22
23
|
bus.emit("search", "nano");
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
В компоненте та же шина не требует лишнего кода. Пример на React; обработчик остаётся в `search.js`:
|
|
27
|
+
|
|
28
|
+
```jsx
|
|
29
|
+
// SearchBox.jsx
|
|
30
|
+
import { useEffect, useState } from "react";
|
|
31
|
+
import { bus } from "./search";
|
|
32
|
+
|
|
33
|
+
export function SearchBox() {
|
|
34
|
+
const [items, setItems] = useState([]);
|
|
35
|
+
|
|
36
|
+
// on возвращает функцию отписки; если вернуть её из эффекта, подписка снимается при размонтировании
|
|
37
|
+
useEffect(() => bus.on("found", setItems), []);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<input onChange={(event) => bus.emit("search", event.target.value)} />
|
|
42
|
+
<ul>
|
|
43
|
+
{items.map((item) => (
|
|
44
|
+
<li key={item.id}>{item.title}</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Тот же пример по шагам: [React](react.md), [Vue](vue.md), [Svelte](svelte.md). Готовые проекты: [Проект из шаблона](starter.md).
|
|
26
53
|
|
|
27
54
|
## Установка
|
|
28
55
|
|
|
@@ -47,7 +47,7 @@ bus.on("results", (items) => render(items));
|
|
|
47
47
|
|
|
48
48
|
Что заменить: `api.search` — ваша функция запроса. Она должна принимать `{ signal }` и передавать его в `fetch`. Без этого `latest` по-прежнему игнорирует старый результат, но запрос продолжает выполняться.
|
|
49
49
|
|
|
50
|
-
Чтобы ошибка стала невозможной, привяжите сигнал один раз: `
|
|
50
|
+
Чтобы ошибка стала невозможной, привяжите сигнал один раз: `signalFetch(ctx, url)` — это `fetch`, который останавливается вместе с обработчиком, а `effectsPlugin({ fetch: signalFetch })` даёт каждому обработчику `ctx.fetch` из него ([как](pass-the-signal.md#привязка-сигнала-один-раз)). Чтобы найти запросы, которые сигнал уже забывают, используйте `devPlugin` при разработке, а в тесте `getLostSignalsCount(bus)` ([как](pass-the-signal.md#подсчёт-потерянных-сигналов-в-тесте)).
|
|
51
51
|
|
|
52
52
|
Результат: после `emit("search", "ab")` и `emit("search", "abc")` вы увидите только результаты для `abc`, а запрос для `ab` отменится.
|
|
53
53
|
|
package/docs/ru/context.md
CHANGED
|
@@ -99,7 +99,7 @@ bus.on("user/open", async (id, ctx) => {
|
|
|
99
99
|
});
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
- `signalFetch` из той же точки входа
|
|
102
|
+
- Для `fetch` `fromSignal` не нужен: `signalFetch(ctx, url)` из той же точки входа останавливается вместе с задачей, а `effectsPlugin({ fetch: signalFetch })` даёт каждому обработчику `ctx.fetch`. [Привязка сигнала один раз](pass-the-signal.md#привязка-сигнала-один-раз)
|
|
103
103
|
- `make` запускается при первом чтении, поэтому обработчик, который никогда не использует `ctx.http`, ничего за него не платит.
|
|
104
104
|
- Задача, которую запускает `ctx.run`, строит своё значение из своего сигнала, так что прерывание дочерней задачи останавливает только её запросы.
|
|
105
105
|
- Поле не копируется через `{ ...ctx }`, и `Object.keys(ctx)` его не перечисляет. Если вы копируете контекст, сначала прочитайте `ctx.http`.
|
package/docs/ru/package.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
| `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` должен вернуть результат или доменная логика уходит из UI |
|
|
18
18
|
| `nanoeffects/match` | `matchPlugin`: `on`, `once` и `off` принимают функцию-фильтр вместо имени события | подписываться нужно по условию, а не по имени |
|
|
19
19
|
| `nanoeffects/context` | `contextPlugin`: ваши собственные поля (store, клиент) на `ctx` каждой задачи, с типами по значению | store или клиент должен быть на каждой задаче |
|
|
20
|
-
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` привязывает задачи этого `emit` к внешнему `AbortSignal`; `
|
|
20
|
+
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` привязывает задачи этого `emit` к внешнему `AbortSignal`; `signalFetch(ctx, url)`: `fetch`, который останавливается вместе с задачей; `fromSignal`: клиент, созданный из сигнала каждой задачи | внешний `AbortSignal` (запрос, React Query) должен останавливать задачи или обработчик не должен передавать сигнал вручную |
|
|
21
21
|
| `nanoeffects/oop` | `Emitter`, `extendBus`, `WithBus`, `On`, `Once`, `disposeBus`: методы шины на классе, подписки каждого экземпляра заканчиваются через `dispose()` | вы пишете классы, а не функции |
|
|
22
22
|
| `nanoeffects/scope` | `scopePlugin`, `effect`: поля для одного action, задачи, модуля или `emit` (типы `Scope`, `Effect`) | одному action или модулю нужны свои поля |
|
|
23
23
|
| `nanoeffects/redux` | `createEffectsMiddleware` | вы уже используете Redux |
|
|
@@ -22,25 +22,36 @@
|
|
|
22
22
|
|
|
23
23
|
## Привязка сигнала один раз
|
|
24
24
|
|
|
25
|
-
Надёжнее всего обработчик, в котором
|
|
25
|
+
Надёжнее всего обработчик, в котором сигнал не пишется вообще. `signalFetch` — это `fetch`, который принимает `ctx` первым аргументом, как `retry(ctx, ...)`: запрос несёт `ctx.signal`, и забыть нечего.
|
|
26
26
|
|
|
27
27
|
```js
|
|
28
|
-
//
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import axios from "axios";
|
|
28
|
+
// search-handlers.js
|
|
29
|
+
import { latest } from "nanoeffects/policies";
|
|
30
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
31
|
+
import { bus } from "./bus";
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
fetch:
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
bus.on(
|
|
34
|
+
"search",
|
|
35
|
+
latest(async (query, ctx) => {
|
|
36
|
+
// signalFetch — fetch с ctx.signal: останавливается, когда политика отменяет обработчик
|
|
37
|
+
const response = await signalFetch(ctx, `/api?q=${query}`);
|
|
38
|
+
ctx.emit("results", await response.json());
|
|
40
39
|
}),
|
|
41
40
|
);
|
|
42
41
|
```
|
|
43
42
|
|
|
43
|
+
Чтобы `ctx.fetch` был в каждом обработчике, положите эффект на контекст один раз. `effectsPlugin` привязывает его к каждой задаче, поэтому обработчик обходится без аргумента `ctx`:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
// bus.js
|
|
47
|
+
import { createBus } from "nanoeffects";
|
|
48
|
+
import { effectsPlugin } from "nanoeffects/effects";
|
|
49
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
50
|
+
|
|
51
|
+
// effectsPlugin — ctx.fetch это signalFetch, привязанный к задаче, которая его читает
|
|
52
|
+
export const bus = createBus().use(effectsPlugin({ fetch: signalFetch }));
|
|
53
|
+
```
|
|
54
|
+
|
|
44
55
|
```js
|
|
45
56
|
// search-handlers.js
|
|
46
57
|
import { latest } from "nanoeffects/policies";
|
|
@@ -48,10 +59,27 @@ import { bus } from "./bus";
|
|
|
48
59
|
|
|
49
60
|
bus.on(
|
|
50
61
|
"search",
|
|
51
|
-
latest(async (query,
|
|
52
|
-
//
|
|
53
|
-
const response = await
|
|
54
|
-
|
|
62
|
+
latest(async (query, { fetch, emit }) => {
|
|
63
|
+
// ни сигнала, ни аргумента ctx: fetch останавливается, когда политика отменяет обработчик
|
|
64
|
+
const response = await fetch(`/api?q=${query}`);
|
|
65
|
+
emit("results", await response.json());
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Клиент, который не является эффектом, например экземпляр `axios`, создаётся из сигнала каждой задачи через `fromSignal`:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
// bus.js
|
|
74
|
+
import { createBus } from "nanoeffects";
|
|
75
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
76
|
+
import { fromSignal } from "nanoeffects/signal";
|
|
77
|
+
import axios from "axios";
|
|
78
|
+
|
|
79
|
+
// fromSignal — ctx.http создаётся из сигнала задачи, которая его читает
|
|
80
|
+
export const bus = createBus().use(
|
|
81
|
+
contextPlugin({
|
|
82
|
+
http: fromSignal((signal) => axios.create({ signal })),
|
|
55
83
|
}),
|
|
56
84
|
);
|
|
57
85
|
```
|
|
@@ -105,7 +133,7 @@ bus.on(
|
|
|
105
133
|
| Ситуация | Что сделать | Линтер | devPlugin |
|
|
106
134
|
| -------- | ----------- | :----: | :-------: |
|
|
107
135
|
| У обработчика нет `ctx`: `latest(async (query) => ...)` | Возьмите `(query, ctx)` и передайте `ctx.signal` | да | |
|
|
108
|
-
| `ctx` взят, но в запрос не передан | Передайте `{ signal: ctx.signal }` в каждый запрос или привяжите его один раз: `ctx
|
|
136
|
+
| `ctx` взят, но в запрос не передан | Передайте `{ signal: ctx.signal }` в каждый запрос или привяжите его один раз: `signalFetch(ctx, url)` ([выше](#привязка-сигнала-один-раз)) | да | да |
|
|
109
137
|
| Один шаг передаёт сигнал, другой нет | Передайте его каждому шагу | да | да |
|
|
110
138
|
| Сигнал лежит в переменной: `const options = { signal: ctx.signal }` | Ничего: переменная с сигналом считается | принимает | |
|
|
111
139
|
| Вызовы идут вместе: `Promise.all([a(), b()])` | Передайте сигнал каждому вызову, а не одному | да | |
|
|
@@ -123,7 +151,7 @@ bus.on(
|
|
|
123
151
|
| Функция принимает сигнал и не использует его: `search(query, options) { return fetch(url); }` | Передайте сигнал дальше внутри неё. В линтере это находит только информация о типах; тест находит наверняка | типы | |
|
|
124
152
|
| Сигнал подключён вручную: `signal.addEventListener("abort", ...)` | Ничего: такой обработчик не проверяется по шагам | принимает | |
|
|
125
153
|
| `bus.emit` в обработчике | Используйте `ctx.emit`: только его отбрасывают после отмены обработчика | да | |
|
|
126
|
-
| Запрос продолжает идти после отмены, на Node | Передайте сигнал или используйте `ctx
|
|
154
|
+
| Запрос продолжает идти после отмены, на Node | Передайте сигнал или используйте `signalFetch(ctx, url)` | | да |
|
|
127
155
|
| Результаты отправлены после отмены | Обработчик проигнорировал сигнал. Передайте его или проверьте `ctx.signal.aborted` | | да |
|
|
128
156
|
| `ctx.on` после отмены | То же | | да |
|
|
129
157
|
|
package/docs/ru/policies.md
CHANGED
|
@@ -24,7 +24,7 @@ bus.on(
|
|
|
24
24
|
);
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Передайте `signal` в `fetch` (и подобные вызовы), чтобы запрос отменялся. Чтобы не передавать его вообще, привяжите сигнал один раз: с `ctx
|
|
27
|
+
Передайте `signal` в `fetch` (и подобные вызовы), чтобы запрос отменялся. Чтобы не передавать его вообще, привяжите сигнал один раз: с `signalFetch(ctx, url)` запрос останавливается вместе с обработчиком, и забыть нечего ([Сохранение сигнала в обработчике](pass-the-signal.md#привязка-сигнала-один-раз)).
|
|
28
28
|
|
|
29
29
|
## Таблица политик
|
|
30
30
|
|
package/docs/ru/react.md
CHANGED
|
@@ -8,6 +8,47 @@
|
|
|
8
8
|
|
|
9
9
|
Нужен проект на React с установленной `nanoeffects`. Форма общается с вашим клиентом, `api` ниже: замените его своими запросами.
|
|
10
10
|
|
|
11
|
+
Самый короткий вариант: поле имени, которое спрашивает backend, свободно ли оно. Остальная страница строит из него форму целиком.
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
// username.js
|
|
15
|
+
import { createBus } from "nanoeffects";
|
|
16
|
+
import { latest } from "nanoeffects/policies";
|
|
17
|
+
|
|
18
|
+
export const bus = createBus(); // шина, создаётся один раз
|
|
19
|
+
|
|
20
|
+
// проверка имени вне компонента; latest прерывает проверку предыдущего имени
|
|
21
|
+
bus.on(
|
|
22
|
+
"username/typed",
|
|
23
|
+
latest(async (name, ctx) => {
|
|
24
|
+
const available = await api.usernameAvailable(name, { signal: ctx.signal });
|
|
25
|
+
ctx.emit("username/checked", available);
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```jsx
|
|
31
|
+
// NameField.jsx
|
|
32
|
+
import { useEffect, useState } from "react";
|
|
33
|
+
import { bus } from "./username";
|
|
34
|
+
|
|
35
|
+
export function NameField() {
|
|
36
|
+
const [available, setAvailable] = useState(null); // null: not checked yet
|
|
37
|
+
|
|
38
|
+
// on возвращает функцию отписки; если вернуть её из эффекта, подписка снимается при размонтировании
|
|
39
|
+
useEffect(() => bus.on("username/checked", setAvailable), []);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<input onChange={(event) => bus.emit("username/typed", event.target.value)} />
|
|
44
|
+
{available === false && <p>This name is taken</p>}
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Обработчик ничего не знает о React, а компонент ничего не знает о запросах.
|
|
51
|
+
|
|
11
52
|
## На этой странице
|
|
12
53
|
|
|
13
54
|
- [Проблемы формы без шины](#проблемы-формы-без-шины)
|
package/docs/ru/svelte.md
CHANGED
|
@@ -6,6 +6,47 @@
|
|
|
6
6
|
|
|
7
7
|
Нужны Svelte 5 или новее и установленная `nanoeffects`. Не знакомы с шиной? Сначала прочитайте [Руководство](guide.md).
|
|
8
8
|
|
|
9
|
+
Самый короткий вариант: поле поиска, которое показывает найденное обработчиком. Плагин для этого не нужен; разделы ниже добавляют помощник, который сокращает компонент.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
// search.js
|
|
13
|
+
import { createBus } from "nanoeffects";
|
|
14
|
+
import { latest } from "nanoeffects/policies";
|
|
15
|
+
|
|
16
|
+
export const bus = createBus(); // шина, создаётся один раз
|
|
17
|
+
|
|
18
|
+
// логика поиска вне компонента; latest прерывает предыдущий запрос
|
|
19
|
+
bus.on(
|
|
20
|
+
"search",
|
|
21
|
+
latest(async (query, ctx) => {
|
|
22
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
23
|
+
ctx.emit("found", items);
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Обработчик ничего не знает об UI, а компонент только отправляет `search` и слушает `found`.
|
|
29
|
+
|
|
30
|
+
<!-- docs-check: skip - a .svelte file -->
|
|
31
|
+
```svelte
|
|
32
|
+
<script>
|
|
33
|
+
import { bus } from "./search";
|
|
34
|
+
|
|
35
|
+
let items = $state([]);
|
|
36
|
+
|
|
37
|
+
// on возвращает функцию отписки; $effect вызывает её при очистке
|
|
38
|
+
$effect(() => bus.on("found", (found) => (items = found)));
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<input oninput={(event) => bus.emit("search", event.target.value)} />
|
|
42
|
+
<ul>
|
|
43
|
+
{#each items as item (item.id)}
|
|
44
|
+
<li>{item.title}</li>
|
|
45
|
+
{/each}
|
|
46
|
+
</ul>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
|
|
9
50
|
## Плагин на шине
|
|
10
51
|
|
|
11
52
|
**Проблема.** Компонент Svelte читает значение через `$store`, а событие шины — не store. Без помощи компонент пишет свой `readable` и `bus.on` и должен не забыть отписаться. Эти строки копируются в каждый компонент.
|
package/docs/ru/vue.md
CHANGED
|
@@ -6,6 +6,51 @@
|
|
|
6
6
|
|
|
7
7
|
Нужны Vue 3.3 или новее и установленная `nanoeffects`. Не знакомы с шиной? Сначала прочитайте [Руководство](guide.md).
|
|
8
8
|
|
|
9
|
+
Самый короткий вариант: поле поиска, которое показывает найденное обработчиком. Плагин для этого не нужен; разделы ниже добавляют помощник, который сокращает компонент.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
// search.js
|
|
13
|
+
import { createBus } from "nanoeffects";
|
|
14
|
+
import { latest } from "nanoeffects/policies";
|
|
15
|
+
|
|
16
|
+
export const bus = createBus(); // шина, создаётся один раз
|
|
17
|
+
|
|
18
|
+
// логика поиска вне компонента; latest прерывает предыдущий запрос
|
|
19
|
+
bus.on(
|
|
20
|
+
"search",
|
|
21
|
+
latest(async (query, ctx) => {
|
|
22
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
23
|
+
ctx.emit("found", items);
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Обработчик ничего не знает об UI, а компонент только отправляет `search` и слушает `found`.
|
|
29
|
+
|
|
30
|
+
<!-- docs-check: skip - a .vue file -->
|
|
31
|
+
```vue
|
|
32
|
+
<script setup>
|
|
33
|
+
import { onUnmounted, ref } from "vue";
|
|
34
|
+
import { bus } from "./search";
|
|
35
|
+
|
|
36
|
+
const items = ref([]);
|
|
37
|
+
|
|
38
|
+
// on возвращает функцию отписки; при размонтировании вызываем её
|
|
39
|
+
const unbind = bus.on("found", (found) => {
|
|
40
|
+
items.value = found;
|
|
41
|
+
});
|
|
42
|
+
onUnmounted(unbind);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<input @input="bus.emit('search', $event.target.value)" />
|
|
47
|
+
<ul>
|
|
48
|
+
<li v-for="item in items" :key="item.id">{{ item.title }}</li>
|
|
49
|
+
</ul>
|
|
50
|
+
</template>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
|
|
9
54
|
## Плагин на шине
|
|
10
55
|
|
|
11
56
|
**Проблема.** Без помощи компонент пишет `watch(query, (value) => bus.emit(queryChanged(value)))` и собственную подписку в `onMounted` и `onUnmounted`. Эти строки копируются в каждый компонент, и в каждой копии что-нибудь забыто: очистка, проверка равенства или имя подписки в трассировке.
|
package/docs/svelte.md
CHANGED
|
@@ -6,6 +6,47 @@ Use this page when your app is in Svelte 5 and a component has to show the resul
|
|
|
6
6
|
|
|
7
7
|
You need Svelte 5 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
|
|
8
8
|
|
|
9
|
+
The shortest version: a search box that shows what the handler found. No plugin is needed for this; the sections below add a helper that shortens the component.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
// search.js
|
|
13
|
+
import { createBus } from "nanoeffects";
|
|
14
|
+
import { latest } from "nanoeffects/policies";
|
|
15
|
+
|
|
16
|
+
export const bus = createBus(); // the bus, created once
|
|
17
|
+
|
|
18
|
+
// the search logic, outside the component; latest aborts the previous request
|
|
19
|
+
bus.on(
|
|
20
|
+
"search",
|
|
21
|
+
latest(async (query, ctx) => {
|
|
22
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
23
|
+
ctx.emit("found", items);
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
|
|
29
|
+
|
|
30
|
+
<!-- docs-check: skip - a .svelte file -->
|
|
31
|
+
```svelte
|
|
32
|
+
<script>
|
|
33
|
+
import { bus } from "./search";
|
|
34
|
+
|
|
35
|
+
let items = $state([]);
|
|
36
|
+
|
|
37
|
+
// on returns the unbind function; $effect calls it on cleanup
|
|
38
|
+
$effect(() => bus.on("found", (found) => (items = found)));
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<input oninput={(event) => bus.emit("search", event.target.value)} />
|
|
42
|
+
<ul>
|
|
43
|
+
{#each items as item (item.id)}
|
|
44
|
+
<li>{item.title}</li>
|
|
45
|
+
{/each}
|
|
46
|
+
</ul>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
|
|
9
50
|
## Add the plugin to the bus
|
|
10
51
|
|
|
11
52
|
**Problem.** A Svelte component reads a value with `$store`. An event of the bus is not a store, so without help the component writes its own `readable` and its own `bus.on`, and has to remember to stop listening. The same few lines are copied into every component.
|
package/docs/vue.md
CHANGED
|
@@ -6,6 +6,51 @@ Use this page when your app is in Vue 3 and a component has to talk to the bus:
|
|
|
6
6
|
|
|
7
7
|
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.
|
|
8
8
|
|
|
9
|
+
The shortest version: a search box that shows what the handler found. No plugin is needed for this; the sections below add a helper that shortens the component.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
// search.js
|
|
13
|
+
import { createBus } from "nanoeffects";
|
|
14
|
+
import { latest } from "nanoeffects/policies";
|
|
15
|
+
|
|
16
|
+
export const bus = createBus(); // the bus, created once
|
|
17
|
+
|
|
18
|
+
// the search logic, outside the component; latest aborts the previous request
|
|
19
|
+
bus.on(
|
|
20
|
+
"search",
|
|
21
|
+
latest(async (query, ctx) => {
|
|
22
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
23
|
+
ctx.emit("found", items);
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
|
|
29
|
+
|
|
30
|
+
<!-- docs-check: skip - a .vue file -->
|
|
31
|
+
```vue
|
|
32
|
+
<script setup>
|
|
33
|
+
import { onUnmounted, ref } from "vue";
|
|
34
|
+
import { bus } from "./search";
|
|
35
|
+
|
|
36
|
+
const items = ref([]);
|
|
37
|
+
|
|
38
|
+
// on returns the unbind function; unmounting calls it
|
|
39
|
+
const unbind = bus.on("found", (found) => {
|
|
40
|
+
items.value = found;
|
|
41
|
+
});
|
|
42
|
+
onUnmounted(unbind);
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<input @input="bus.emit('search', $event.target.value)" />
|
|
47
|
+
<ul>
|
|
48
|
+
<li v-for="item in items" :key="item.id">{{ item.title }}</li>
|
|
49
|
+
</ul>
|
|
50
|
+
</template>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
|
|
9
54
|
## Add the plugin to the bus
|
|
10
55
|
|
|
11
56
|
**Problem.** Without help, a component writes `watch(query, (value) => bus.emit(queryChanged(value)))` and its own subscription in `onMounted` and `onUnmounted`. The same few lines are copied into every component, and each copy forgets something: the cleanup, the equality check, or the name of the subscription in the trace.
|
package/llms-full.txt
CHANGED
|
@@ -1312,7 +1312,7 @@ Each row is optional. The "when" column says what need brings you to it.
|
|
|
1312
1312
|
| `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
|
|
1313
1313
|
| `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
|
|
1314
1314
|
| `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 |
|
|
1315
|
-
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal`; `
|
|
1315
|
+
| `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal`; `signalFetch(ctx, url)`: a `fetch` that stops with its task; `fromSignal`: a client made from the signal of each task | an outside `AbortSignal` (request, React Query) must stop tasks, or a handler must not pass the signal by hand |
|
|
1316
1316
|
| `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 |
|
|
1317
1317
|
| `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
|
|
1318
1318
|
| `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
|
|
@@ -1772,7 +1772,7 @@ bus.on(
|
|
|
1772
1772
|
);
|
|
1773
1773
|
```
|
|
1774
1774
|
|
|
1775
|
-
Pass `signal` to `fetch` (and the like) so the request cancels. To not pass it at all, bind it once: with `ctx
|
|
1775
|
+
Pass `signal` to `fetch` (and the like) so the request cancels. To not pass it at all, bind it once: with `signalFetch(ctx, url)` the request stops with the handler and there is nothing to forget ([Keep the signal through a handler](pass-the-signal.md#bind-the-signal-once)).
|
|
1776
1776
|
|
|
1777
1777
|
## Choose a policy
|
|
1778
1778
|
|
|
@@ -2002,7 +2002,7 @@ bus.on("results", (items) => render(items));
|
|
|
2002
2002
|
|
|
2003
2003
|
What to replace: `api.search` is your own request function. It must accept `{ signal }` and give it to `fetch`. Without it, `latest` still ignores the old result, but the request keeps running.
|
|
2004
2004
|
|
|
2005
|
-
To make the mistake impossible, bind the signal once: `
|
|
2005
|
+
To make the mistake impossible, bind the signal once: `signalFetch(ctx, url)` is a `fetch` that stops with the handler, and `effectsPlugin({ fetch: signalFetch })` gives every handler a `ctx.fetch` of it ([how](pass-the-signal.md#bind-the-signal-once)). To find the requests that already forget it, use `devPlugin` while you develop, and `getLostSignalsCount(bus)` in a test ([how](pass-the-signal.md#count-lost-signals-in-a-test)).
|
|
2006
2006
|
|
|
2007
2007
|
Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
|
|
2008
2008
|
|
|
@@ -4623,6 +4623,47 @@ Steps 1 and 2 are required, step 3 is not. The last two sections are extra.
|
|
|
4623
4623
|
|
|
4624
4624
|
You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
|
|
4625
4625
|
|
|
4626
|
+
The shortest version: a name field that asks the backend whether the name is free. The rest of the page builds a whole form from it.
|
|
4627
|
+
|
|
4628
|
+
```js
|
|
4629
|
+
// username.js
|
|
4630
|
+
import { createBus } from "nanoeffects";
|
|
4631
|
+
import { latest } from "nanoeffects/policies";
|
|
4632
|
+
|
|
4633
|
+
export const bus = createBus(); // the bus, created once
|
|
4634
|
+
|
|
4635
|
+
// the name check, outside the component; latest aborts the check of the previous name
|
|
4636
|
+
bus.on(
|
|
4637
|
+
"username/typed",
|
|
4638
|
+
latest(async (name, ctx) => {
|
|
4639
|
+
const available = await api.usernameAvailable(name, { signal: ctx.signal });
|
|
4640
|
+
ctx.emit("username/checked", available);
|
|
4641
|
+
}),
|
|
4642
|
+
);
|
|
4643
|
+
```
|
|
4644
|
+
|
|
4645
|
+
```jsx
|
|
4646
|
+
// NameField.jsx
|
|
4647
|
+
import { useEffect, useState } from "react";
|
|
4648
|
+
import { bus } from "./username";
|
|
4649
|
+
|
|
4650
|
+
export function NameField() {
|
|
4651
|
+
const [available, setAvailable] = useState(null); // null: not checked yet
|
|
4652
|
+
|
|
4653
|
+
// on returns the unbind function; returning it removes the listener on unmount
|
|
4654
|
+
useEffect(() => bus.on("username/checked", setAvailable), []);
|
|
4655
|
+
|
|
4656
|
+
return (
|
|
4657
|
+
<>
|
|
4658
|
+
<input onChange={(event) => bus.emit("username/typed", event.target.value)} />
|
|
4659
|
+
{available === false && <p>This name is taken</p>}
|
|
4660
|
+
</>
|
|
4661
|
+
);
|
|
4662
|
+
}
|
|
4663
|
+
```
|
|
4664
|
+
|
|
4665
|
+
The handler knows nothing about React, and the component knows nothing about requests.
|
|
4666
|
+
|
|
4626
4667
|
## On this page
|
|
4627
4668
|
|
|
4628
4669
|
- [Problems of the form without a bus](#problems-of-the-form-without-a-bus)
|
|
@@ -5418,6 +5459,51 @@ Use this page when your app is in Vue 3 and a component has to talk to the bus:
|
|
|
5418
5459
|
|
|
5419
5460
|
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.
|
|
5420
5461
|
|
|
5462
|
+
The shortest version: a search box that shows what the handler found. No plugin is needed for this; the sections below add a helper that shortens the component.
|
|
5463
|
+
|
|
5464
|
+
```js
|
|
5465
|
+
// search.js
|
|
5466
|
+
import { createBus } from "nanoeffects";
|
|
5467
|
+
import { latest } from "nanoeffects/policies";
|
|
5468
|
+
|
|
5469
|
+
export const bus = createBus(); // the bus, created once
|
|
5470
|
+
|
|
5471
|
+
// the search logic, outside the component; latest aborts the previous request
|
|
5472
|
+
bus.on(
|
|
5473
|
+
"search",
|
|
5474
|
+
latest(async (query, ctx) => {
|
|
5475
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
5476
|
+
ctx.emit("found", items);
|
|
5477
|
+
}),
|
|
5478
|
+
);
|
|
5479
|
+
```
|
|
5480
|
+
|
|
5481
|
+
The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
|
|
5482
|
+
|
|
5483
|
+
<!-- docs-check: skip - a .vue file -->
|
|
5484
|
+
```vue
|
|
5485
|
+
<script setup>
|
|
5486
|
+
import { onUnmounted, ref } from "vue";
|
|
5487
|
+
import { bus } from "./search";
|
|
5488
|
+
|
|
5489
|
+
const items = ref([]);
|
|
5490
|
+
|
|
5491
|
+
// on returns the unbind function; unmounting calls it
|
|
5492
|
+
const unbind = bus.on("found", (found) => {
|
|
5493
|
+
items.value = found;
|
|
5494
|
+
});
|
|
5495
|
+
onUnmounted(unbind);
|
|
5496
|
+
</script>
|
|
5497
|
+
|
|
5498
|
+
<template>
|
|
5499
|
+
<input @input="bus.emit('search', $event.target.value)" />
|
|
5500
|
+
<ul>
|
|
5501
|
+
<li v-for="item in items" :key="item.id">{{ item.title }}</li>
|
|
5502
|
+
</ul>
|
|
5503
|
+
</template>
|
|
5504
|
+
```
|
|
5505
|
+
|
|
5506
|
+
|
|
5421
5507
|
## Add the plugin to the bus
|
|
5422
5508
|
|
|
5423
5509
|
**Problem.** Without help, a component writes `watch(query, (value) => bus.emit(queryChanged(value)))` and its own subscription in `onMounted` and `onUnmounted`. The same few lines are copied into every component, and each copy forgets something: the cleanup, the equality check, or the name of the subscription in the trace.
|
|
@@ -5520,6 +5606,47 @@ Use this page when your app is in Svelte 5 and a component has to show the resul
|
|
|
5520
5606
|
|
|
5521
5607
|
You need Svelte 5 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
|
|
5522
5608
|
|
|
5609
|
+
The shortest version: a search box that shows what the handler found. No plugin is needed for this; the sections below add a helper that shortens the component.
|
|
5610
|
+
|
|
5611
|
+
```js
|
|
5612
|
+
// search.js
|
|
5613
|
+
import { createBus } from "nanoeffects";
|
|
5614
|
+
import { latest } from "nanoeffects/policies";
|
|
5615
|
+
|
|
5616
|
+
export const bus = createBus(); // the bus, created once
|
|
5617
|
+
|
|
5618
|
+
// the search logic, outside the component; latest aborts the previous request
|
|
5619
|
+
bus.on(
|
|
5620
|
+
"search",
|
|
5621
|
+
latest(async (query, ctx) => {
|
|
5622
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
5623
|
+
ctx.emit("found", items);
|
|
5624
|
+
}),
|
|
5625
|
+
);
|
|
5626
|
+
```
|
|
5627
|
+
|
|
5628
|
+
The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
|
|
5629
|
+
|
|
5630
|
+
<!-- docs-check: skip - a .svelte file -->
|
|
5631
|
+
```svelte
|
|
5632
|
+
<script>
|
|
5633
|
+
import { bus } from "./search";
|
|
5634
|
+
|
|
5635
|
+
let items = $state([]);
|
|
5636
|
+
|
|
5637
|
+
// on returns the unbind function; $effect calls it on cleanup
|
|
5638
|
+
$effect(() => bus.on("found", (found) => (items = found)));
|
|
5639
|
+
</script>
|
|
5640
|
+
|
|
5641
|
+
<input oninput={(event) => bus.emit("search", event.target.value)} />
|
|
5642
|
+
<ul>
|
|
5643
|
+
{#each items as item (item.id)}
|
|
5644
|
+
<li>{item.title}</li>
|
|
5645
|
+
{/each}
|
|
5646
|
+
</ul>
|
|
5647
|
+
```
|
|
5648
|
+
|
|
5649
|
+
|
|
5523
5650
|
## Add the plugin to the bus
|
|
5524
5651
|
|
|
5525
5652
|
**Problem.** A Svelte component reads a value with `$store`. An event of the bus is not a store, so without help the component writes its own `readable` and its own `bus.on`, and has to remember to stop listening. The same few lines are copied into every component.
|
|
@@ -5700,7 +5827,7 @@ bus.on("user/open", async (id, ctx) => {
|
|
|
5700
5827
|
});
|
|
5701
5828
|
```
|
|
5702
5829
|
|
|
5703
|
-
- `signalFetch` from the same entry
|
|
5830
|
+
- For `fetch` you need no `fromSignal`: `signalFetch(ctx, url)` from the same entry stops with the task, and `effectsPlugin({ fetch: signalFetch })` gives every handler a `ctx.fetch`. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
|
|
5704
5831
|
- `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
|
|
5705
5832
|
- A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
|
|
5706
5833
|
- The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
|
|
@@ -7413,25 +7540,36 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
|
|
|
7413
7540
|
|
|
7414
7541
|
## Bind the signal once
|
|
7415
7542
|
|
|
7416
|
-
The surest fix is a handler
|
|
7543
|
+
The surest fix is a handler that never writes a signal. `signalFetch` is a `fetch` that takes `ctx` first, like `retry(ctx, ...)`: the request carries `ctx.signal`, so there is nothing to forget.
|
|
7417
7544
|
|
|
7418
7545
|
```js
|
|
7419
|
-
//
|
|
7420
|
-
import {
|
|
7421
|
-
import {
|
|
7422
|
-
import {
|
|
7423
|
-
import axios from "axios";
|
|
7546
|
+
// search-handlers.js
|
|
7547
|
+
import { latest } from "nanoeffects/policies";
|
|
7548
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
7549
|
+
import { bus } from "./bus";
|
|
7424
7550
|
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
fetch:
|
|
7429
|
-
|
|
7430
|
-
|
|
7551
|
+
bus.on(
|
|
7552
|
+
"search",
|
|
7553
|
+
latest(async (query, ctx) => {
|
|
7554
|
+
// signalFetch — fetch with ctx.signal: it stops when the policy cancels the handler
|
|
7555
|
+
const response = await signalFetch(ctx, `/api?q=${query}`);
|
|
7556
|
+
ctx.emit("results", await response.json());
|
|
7431
7557
|
}),
|
|
7432
7558
|
);
|
|
7433
7559
|
```
|
|
7434
7560
|
|
|
7561
|
+
To get `ctx.fetch` in every handler, put the effect on the context once. `effectsPlugin` binds it to each task, so the handler drops the `ctx` argument:
|
|
7562
|
+
|
|
7563
|
+
```js
|
|
7564
|
+
// bus.js
|
|
7565
|
+
import { createBus } from "nanoeffects";
|
|
7566
|
+
import { effectsPlugin } from "nanoeffects/effects";
|
|
7567
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
7568
|
+
|
|
7569
|
+
// effectsPlugin — ctx.fetch is signalFetch bound to the task that reads it
|
|
7570
|
+
export const bus = createBus().use(effectsPlugin({ fetch: signalFetch }));
|
|
7571
|
+
```
|
|
7572
|
+
|
|
7435
7573
|
```js
|
|
7436
7574
|
// search-handlers.js
|
|
7437
7575
|
import { latest } from "nanoeffects/policies";
|
|
@@ -7439,10 +7577,27 @@ import { bus } from "./bus";
|
|
|
7439
7577
|
|
|
7440
7578
|
bus.on(
|
|
7441
7579
|
"search",
|
|
7442
|
-
latest(async (query,
|
|
7443
|
-
// no signal
|
|
7444
|
-
const response = await
|
|
7445
|
-
|
|
7580
|
+
latest(async (query, { fetch, emit }) => {
|
|
7581
|
+
// no signal and no ctx argument: fetch stops when the policy cancels the handler
|
|
7582
|
+
const response = await fetch(`/api?q=${query}`);
|
|
7583
|
+
emit("results", await response.json());
|
|
7584
|
+
}),
|
|
7585
|
+
);
|
|
7586
|
+
```
|
|
7587
|
+
|
|
7588
|
+
A client that is not an effect, such as an `axios` instance, is made from the signal of each task with `fromSignal`:
|
|
7589
|
+
|
|
7590
|
+
```js
|
|
7591
|
+
// bus.js
|
|
7592
|
+
import { createBus } from "nanoeffects";
|
|
7593
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
7594
|
+
import { fromSignal } from "nanoeffects/signal";
|
|
7595
|
+
import axios from "axios";
|
|
7596
|
+
|
|
7597
|
+
// fromSignal — ctx.http is made from the signal of the task that reads it
|
|
7598
|
+
export const bus = createBus().use(
|
|
7599
|
+
contextPlugin({
|
|
7600
|
+
http: fromSignal((signal) => axios.create({ signal })),
|
|
7446
7601
|
}),
|
|
7447
7602
|
);
|
|
7448
7603
|
```
|
|
@@ -7514,7 +7669,7 @@ Each row is a situation where the signal is easy to lose. The right column says
|
|
|
7514
7669
|
| 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 | |
|
|
7515
7670
|
| The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
|
|
7516
7671
|
| `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
|
|
7517
|
-
| A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx
|
|
7672
|
+
| A request that keeps running after the cancel, on Node | Pass the signal, or use `signalFetch(ctx, url)` | | yes |
|
|
7518
7673
|
| Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
|
|
7519
7674
|
| `ctx.on` after the cancel | The same | | yes |
|
|
7520
7675
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nanoeffects",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Business logic and side effects outside UI components, as plain async functions. Tiny, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"event-bus",
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
{
|
|
247
247
|
"name": "signal (plugin)",
|
|
248
248
|
"path": "dist/signal.js",
|
|
249
|
-
"limit": "0.
|
|
249
|
+
"limit": "0.81 kB"
|
|
250
250
|
},
|
|
251
251
|
{
|
|
252
252
|
"name": "context (plugin)",
|