nanoeffects 0.33.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/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
 
@@ -141,15 +141,21 @@ export const sendMail = createAction("mail/send");
141
141
  ```js
142
142
  // bus.js
143
143
  import { createBus } from "nanoeffects";
144
- import { actionsPlugin, setHandler } from "nanoeffects/actions";
145
- import { sendMail } from "./mail";
144
+ import { actionsPlugin } from "nanoeffects/actions";
146
145
 
147
146
  export const bus = createBus();
148
147
 
149
- // actionsPlugin — реестр обработчиков этой шины; setHandler связывает action с его работой
150
- export const actions = bus.use(
151
- actionsPlugin([setHandler(sendMail, smtpSend)]),
152
- );
148
+ // actionsPlugin — реестр обработчиков этой шины
149
+ export const actions = bus.use(actionsPlugin());
150
+ ```
151
+
152
+ ```js
153
+ // mail-handlers.js
154
+ import { actions } from "./bus";
155
+ import { sendMail } from "./mail";
156
+
157
+ // actions.handle — привязывает работу к action на этой шине
158
+ actions.handle(sendMail, smtpSend);
153
159
  ```
154
160
 
155
161
  ```js
@@ -163,7 +169,7 @@ unbind();
163
169
 
164
170
  Обработчик, привязанный в реестре, заставляет `bus.emit(sendMail(mail))` запустить его и вернуть его результат, как и для action со своим обработчиком. Подписки на этот тип получают сообщение первыми. Если обработчик не привязан, `sendMail` снова просто событие.
165
171
 
166
- Реестр живёт в `nanoeffects/actions`, поэтому шина без него этого кода не содержит. Без реестра `emit` запускает собственный обработчик action. `bus.use(actionsPlugin())` задаёт `bus.actions`. Второй `actionsPlugin` привязывает свой список в том же реестре и возвращает его. В `createBus` нет параметра `actions`. Собственный исполнитель назначьте через `bus.actions = { run }`, а добавив ему `bound(type)`, вы сможете отвечать за actions без собственного обработчика.
172
+ Реестр живёт в `nanoeffects/actions`, поэтому шина без него этого кода не содержит. Без реестра `emit` запускает собственный обработчик action. `bus.use(actionsPlugin())` задаёт `bus.actions`. Второй `actionsPlugin` возвращает тот же реестр. В `createBus` нет параметра `actions`. Собственный исполнитель назначьте через `bus.actions = { run }`, а добавив ему `bound(type)`, вы сможете отвечать за actions без собственного обработчика.
167
173
 
168
174
  ### Тип результата привязанного обработчика
169
175
 
@@ -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`.
package/docs/ru/guide.md CHANGED
@@ -376,7 +376,7 @@ bus.emit(double(21)); // 42
376
376
 
377
377
  **Проблема.** Обработчик action лежит в файле фичи, а тесту нужна подделка, или работа принадлежит другому слою. Если положить обработчик в `createAction`, файл события окажется связан с работой.
378
378
 
379
- **Что делать.** Создайте action без обработчика: это событие. Настоящий обработчик привяжите через `setHandler` при создании шины. Позже `actions.handle` привязывает новый обработчик, который главнее, пока вы его не снимете.
379
+ **Что делать.** Создайте action без обработчика: это событие. Настоящий обработчик привяжите через `actions.handle` при создании шины. Следующий `actions.handle` привязывает новый обработчик, который главнее, пока вы его не снимете.
380
380
 
381
381
  ```js
382
382
  // user.js
@@ -388,16 +388,16 @@ export const getUser = createAction("user/get"); // обработчика по
388
388
  ```js
389
389
  // bus.js
390
390
  import { createBus } from "nanoeffects";
391
- import { actionsPlugin, setHandler } from "nanoeffects/actions";
391
+ import { actionsPlugin } from "nanoeffects/actions";
392
392
  import { getUser } from "./user";
393
393
 
394
394
  export const bus = createBus();
395
395
 
396
- // setHandler — привязывает настоящий обработчик к action
397
- const realUser = setHandler(getUser, (id) => api.user(id));
398
-
399
396
  // actionsPlugin — реестр привязанных обработчиков этой шины
400
- export const actions = bus.use(actionsPlugin([realUser]));
397
+ export const actions = bus.use(actionsPlugin());
398
+
399
+ // actions.handle — привязывает настоящий обработчик к action
400
+ actions.handle(getUser, (id) => api.user(id));
401
401
  ```
402
402
 
403
403
  ```js
@@ -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.