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 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 carries a given signal, so the request stops when the signal aborts and the caller never passes
6
- * it. Give it to `fromSignal` and every task gets a `ctx.fetch` made from its own signal. A call that has a
7
- * `signal` of its own stops with whichever aborts first.
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.fetch(`/api?q=${query}`); // stops when the policy cancels the handler
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: (signal: AbortSignal) => typeof fetch;
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=>(t,n)=>{let r=n?.signal,i=r?AbortSignal.any([e,r]):e;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};
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
- The same example with a UI framework: [React](react.md), [Vue](vue.md), [Svelte](svelte.md). Ready projects: [Start a project from a template](starter.md).
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: `contextPlugin({ fetch: fromSignal(signalFetch) })` gives every handler a `ctx.fetch` that stops with the handler ([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)).
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 is a `fetch` bound to a signal, so `fetch: fromSignal(signalFetch)` gives every handler a `ctx.fetch` that stops with the task. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
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`; `fromSignal` and `signalFetch`: a client or a `fetch` 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 |
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 |
@@ -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 with no signal in it. Make the client from the signal of the task, once, and the handler calls `ctx.fetch` and cannot forget anything. `signalFetch` is a `fetch` bound to a signal; `fromSignal` builds it for each task.
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
- // bus.js
29
- import { createBus } from "nanoeffects";
30
- import { contextPlugin } from "nanoeffects/context";
31
- import { fromSignal, signalFetch } from "nanoeffects/signal";
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
- // fromSignal — ctx.fetch is made from the signal of the task that reads it
35
- export const bus = createBus().use(
36
- contextPlugin({
37
- fetch: fromSignal(signalFetch),
38
- // your own client too: it is made from the signal of each task
39
- http: fromSignal((signal) => axios.create({ signal })),
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, ctx) => {
52
- // no signal here: ctx.fetch stops when the policy cancels the handler
53
- const response = await ctx.fetch(`/api?q=${query}`);
54
- ctx.emit("results", await response.json());
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.fetch` | | yes |
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.fetch` 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)).
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
- Тот же пример с UI-фреймворком: [React](react.md), [Vue](vue.md), [Svelte](svelte.md). Готовые проекты: [Проект из шаблона](starter.md).
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
- Чтобы ошибка стала невозможной, привяжите сигнал один раз: `contextPlugin({ fetch: fromSignal(signalFetch) })` даёт каждому обработчику `ctx.fetch`, который останавливается вместе с обработчиком ([как](pass-the-signal.md#привязка-сигнала-один-раз)). Чтобы найти запросы, которые сигнал уже забывают, используйте `devPlugin` при разработке, а в тесте `getLostSignalsCount(bus)` ([как](pass-the-signal.md#подсчёт-потерянных-сигналов-в-тесте)).
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
 
@@ -99,7 +99,7 @@ bus.on("user/open", async (id, ctx) => {
99
99
  });
100
100
  ```
101
101
 
102
- - `signalFetch` из той же точки входа — это `fetch`, привязанный к сигналу, поэтому `fetch: fromSignal(signalFetch)` даёт каждому обработчику `ctx.fetch`, который останавливается вместе с задачей. [Привязка сигнала один раз](pass-the-signal.md#привязка-сигнала-один-раз)
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`.
@@ -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`; `fromSignal` и `signalFetch`: клиент или `fetch`, созданный из сигнала каждой задачи | внешний `AbortSignal` (запрос, React Query) должен останавливать задачи или обработчик не должен передавать сигнал вручную |
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
- Надёжнее всего обработчик, в котором сигнала нет вообще. Создайте клиент из сигнала задачи один раз, и обработчик вызывает `ctx.fetch`, ничего не забывая. `signalFetch` — это `fetch`, привязанный к сигналу; `fromSignal` создаёт его для каждой задачи.
25
+ Надёжнее всего обработчик, в котором сигнал не пишется вообще. `signalFetch` — это `fetch`, который принимает `ctx` первым аргументом, как `retry(ctx, ...)`: запрос несёт `ctx.signal`, и забыть нечего.
26
26
 
27
27
  ```js
28
- // bus.js
29
- import { createBus } from "nanoeffects";
30
- import { contextPlugin } from "nanoeffects/context";
31
- import { fromSignal, signalFetch } from "nanoeffects/signal";
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
- // fromSignal — ctx.fetch создаётся из сигнала задачи, которая его читает
35
- export const bus = createBus().use(
36
- contextPlugin({
37
- fetch: fromSignal(signalFetch),
38
- // и ваш клиент тоже: он создаётся из сигнала каждой задачи
39
- http: fromSignal((signal) => axios.create({ signal })),
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, ctx) => {
52
- // сигнала здесь нет: ctx.fetch останавливается, когда политика отменяет обработчик
53
- const response = await ctx.fetch(`/api?q=${query}`);
54
- ctx.emit("results", await response.json());
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.fetch` ([выше](#привязка-сигнала-один-раз)) | да | да |
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.fetch` | | да |
154
+ | Запрос продолжает идти после отмены, на Node | Передайте сигнал или используйте `signalFetch(ctx, url)` | | да |
127
155
  | Результаты отправлены после отмены | Обработчик проигнорировал сигнал. Передайте его или проверьте `ctx.signal.aborted` | | да |
128
156
  | `ctx.on` после отмены | То же | | да |
129
157
 
@@ -24,7 +24,7 @@ bus.on(
24
24
  );
25
25
  ```
26
26
 
27
- Передайте `signal` в `fetch` (и подобные вызовы), чтобы запрос отменялся. Чтобы не передавать его вообще, привяжите сигнал один раз: с `ctx.fetch` запрос останавливается вместе с обработчиком, и забыть нечего ([Сохранение сигнала в обработчике](pass-the-signal.md#привязка-сигнала-один-раз)).
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`; `fromSignal` and `signalFetch`: a client or a `fetch` 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 |
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.fetch` 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)).
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: `contextPlugin({ fetch: fromSignal(signalFetch) })` gives every handler a `ctx.fetch` that stops with the handler ([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)).
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 is a `fetch` bound to a signal, so `fetch: fromSignal(signalFetch)` gives every handler a `ctx.fetch` that stops with the task. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
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 with no signal in it. Make the client from the signal of the task, once, and the handler calls `ctx.fetch` and cannot forget anything. `signalFetch` is a `fetch` bound to a signal; `fromSignal` builds it for each task.
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
- // bus.js
7420
- import { createBus } from "nanoeffects";
7421
- import { contextPlugin } from "nanoeffects/context";
7422
- import { fromSignal, signalFetch } from "nanoeffects/signal";
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
- // fromSignal — ctx.fetch is made from the signal of the task that reads it
7426
- export const bus = createBus().use(
7427
- contextPlugin({
7428
- fetch: fromSignal(signalFetch),
7429
- // your own client too: it is made from the signal of each task
7430
- http: fromSignal((signal) => axios.create({ signal })),
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, ctx) => {
7443
- // no signal here: ctx.fetch stops when the policy cancels the handler
7444
- const response = await ctx.fetch(`/api?q=${query}`);
7445
- ctx.emit("results", await response.json());
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.fetch` | | yes |
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.34.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.8 kB"
249
+ "limit": "0.81 kB"
250
250
  },
251
251
  {
252
252
  "name": "context (plugin)",