nanoeffects 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/{action-creator-DprJV-pN.d.ts → action-creator-CXIq6GWK.d.ts} +1 -1
  2. package/dist/actions.d.ts +3 -3
  3. package/dist/{context-types-BiTMp_lW.d.ts → context-types-BO1QE6W2.d.ts} +1 -1
  4. package/dist/context.d.ts +2 -2
  5. package/dist/dev.d.ts +2 -2
  6. package/dist/effects.d.ts +2 -2
  7. package/dist/guard.d.ts +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/match.d.ts +1 -1
  10. package/dist/{module-Fk4OtkZk.d.ts → module-BXNaEXE9.d.ts} +2 -2
  11. package/dist/notrace/{action-creator-BWd9FWt6.d.ts → action-creator-JUwIsFUl.d.ts} +1 -1
  12. package/dist/notrace/actions.d.ts +2 -2
  13. package/dist/notrace/effects.d.ts +2 -2
  14. package/dist/notrace/index.d.ts +1 -1
  15. package/dist/notrace/{plugin-DmAYT3Xk.d.ts → plugin-dLdTM2JL.d.ts} +1 -1
  16. package/dist/notrace/policies.d.ts +158 -2
  17. package/dist/notrace/policies.js +1 -1
  18. package/dist/notrace/redux.d.ts +1 -1
  19. package/dist/observable.d.ts +1 -1
  20. package/dist/oop.d.ts +1 -1
  21. package/dist/{plugin-2d3uuW7o.d.ts → plugin-B8La3K9m.d.ts} +2 -2
  22. package/dist/policies.d.ts +159 -3
  23. package/dist/policies.js +1 -1
  24. package/dist/react.d.ts +2 -2
  25. package/dist/redux.d.ts +1 -1
  26. package/dist/scope.d.ts +2 -2
  27. package/dist/signal.d.ts +12 -11
  28. package/dist/signal.js +1 -1
  29. package/dist/svelte.d.ts +1 -1
  30. package/dist/testing.d.ts +1 -1
  31. package/dist/trace.d.ts +1 -1
  32. package/dist/{types-DxPZDzZG.d.ts → types-ByoeMqVh.d.ts} +2 -2
  33. package/dist/vue.d.ts +1 -1
  34. package/dist/{with-policy-Y12d-yx6.d.ts → with-policy-e4yx1vhN.d.ts} +1 -1
  35. package/docs/README.md +29 -2
  36. package/docs/cancel-stale-requests.md +1 -1
  37. package/docs/context.md +1 -1
  38. package/docs/package.md +2 -2
  39. package/docs/pass-the-signal.md +45 -17
  40. package/docs/policies.md +189 -1
  41. package/docs/react.md +41 -0
  42. package/docs/recipes.md +90 -0
  43. package/docs/ru/README.md +29 -2
  44. package/docs/ru/cancel-stale-requests.md +1 -1
  45. package/docs/ru/context.md +1 -1
  46. package/docs/ru/package.md +2 -2
  47. package/docs/ru/pass-the-signal.md +46 -18
  48. package/docs/ru/policies.md +189 -1
  49. package/docs/ru/react.md +41 -0
  50. package/docs/ru/recipes.md +90 -0
  51. package/docs/ru/svelte.md +41 -0
  52. package/docs/ru/vue.md +45 -0
  53. package/docs/svelte.md +41 -0
  54. package/docs/vue.md +45 -0
  55. package/llms-full.txt +455 -22
  56. package/llms.txt +1 -1
  57. package/package.json +3 -3
package/docs/recipes.md CHANGED
@@ -16,6 +16,8 @@ Each task below starts with the problem, then a short solution. Where the page h
16
16
  - [A second click sends a second request](#a-second-click-sends-a-second-request)
17
17
  - [Messages must go out in order](#messages-must-go-out-in-order)
18
18
  - [A "Delete" button in a list blocks the other rows](#a-delete-button-in-a-list-blocks-the-other-rows)
19
+ - [Several components ask for the same data](#several-components-ask-for-the-same-data)
20
+ - [A form saves the same text twice](#a-form-saves-the-same-text-twice)
19
21
  - [An unreliable network and long flows](#an-unreliable-network-and-long-flows)
20
22
  - [A request fails sometimes: retry and backoff](#a-request-fails-sometimes-retry-and-backoff)
21
23
  - [A request hangs: timeout](#a-request-hangs-timeout)
@@ -23,6 +25,8 @@ Each task below starts with the problem, then a short solution. Where the page h
23
25
  - [Wait for another event inside a handler: take](#wait-for-another-event-inside-a-handler-take)
24
26
  - [Load several things at once: all](#load-several-things-at-once-all)
25
27
  - [Take the fastest answer: race](#take-the-fastest-answer-race)
28
+ - [An API allows only N requests per second](#an-api-allows-only-n-requests-per-second)
29
+ - [Many small events, one request](#many-small-events-one-request)
26
30
  - [Features and domain logic](#features-and-domain-logic)
27
31
  - [Get the result of emit](#get-the-result-of-emit)
28
32
  - [Group a feature with its handlers: modules](#group-a-feature-with-its-handlers-modules)
@@ -238,6 +242,49 @@ bus.on(
238
242
 
239
243
  More: [Policies, `key`](policies.md#key-isolate-state-by-key).
240
244
 
245
+ ### Several components ask for the same data
246
+
247
+ A page shows a user in the header, in a sidebar and in a comment, and all three ask for user `1` in the same tick. That is three identical requests. `dedupe` makes the calls for one id share a single request. Use it for reads, not for writes.
248
+
249
+ ```js
250
+ import { createBus } from "nanoeffects";
251
+ import { dedupe } from "nanoeffects/policies";
252
+
253
+ const bus = createBus();
254
+
255
+ // dedupe — a call for an id that is loading already waits for that request
256
+ bus.on(
257
+ "user/needed",
258
+ dedupe(async (id, ctx) => {
259
+ const user = await api.getUser(id, { signal: ctx.signal });
260
+ ctx.emit("user/loaded", user);
261
+ }),
262
+ );
263
+ ```
264
+
265
+ More: [Policies, `dedupe`](policies.md#dedupe-share-one-run-between-equal-calls).
266
+
267
+ ### A form saves the same text twice
268
+
269
+ A form saves on every change. A user types a letter and deletes it, and the same text goes to the server again. `distinct` skips a call whose payload equals the last one.
270
+
271
+ ```js
272
+ import { createBus } from "nanoeffects";
273
+ import { distinct } from "nanoeffects/policies";
274
+
275
+ const bus = createBus();
276
+
277
+ // distinct — a text equal to the last saved one is skipped
278
+ bus.on(
279
+ "draft/changed",
280
+ distinct(async (text, { signal }) => {
281
+ await api.saveDraft(text, { signal });
282
+ }),
283
+ );
284
+ ```
285
+
286
+ More: [Policies, `distinct`](policies.md#distinct-skip-a-repeated-payload).
287
+
241
288
  ## An unreliable network and long flows
242
289
 
243
290
  ### A request fails sometimes: retry and backoff
@@ -369,6 +416,49 @@ bus.on("user/opened", async (id, ctx) => {
369
416
 
370
417
  More: [`all`](effects.md#all), [`race`](effects.md#race).
371
418
 
419
+ ### An API allows only N requests per second
420
+
421
+ A geocoding API allows 5 requests per second. A list of 40 addresses sends 40 requests at once, and most fail with `429`. `rateLimit` starts five and holds the rest until the window slides.
422
+
423
+ ```js
424
+ import { createBus } from "nanoeffects";
425
+ import { rateLimit } from "nanoeffects/policies";
426
+
427
+ const bus = createBus();
428
+
429
+ // rateLimit — five requests may start per second, the others wait their turn
430
+ bus.on(
431
+ "address/geocode",
432
+ rateLimit(5, 1000, async (address, ctx) => {
433
+ const place = await api.geocode(address, { signal: ctx.signal });
434
+ ctx.emit("address/found", place);
435
+ }),
436
+ );
437
+ ```
438
+
439
+ More: [Policies, `rateLimit`](policies.md#ratelimit-stay-under-a-quota).
440
+
441
+ ### Many small events, one request
442
+
443
+ A page sends an analytics event on every click, 30 requests a minute. The server prefers one request with all of them. `batch` collects the events that come within a window and sends them together.
444
+
445
+ ```js
446
+ import { createBus } from "nanoeffects";
447
+ import { batch } from "nanoeffects/policies";
448
+
449
+ const bus = createBus();
450
+
451
+ // batch — everything that comes within 500 ms goes in one request
452
+ bus.on(
453
+ "analytics/event",
454
+ batch(500, async (events, { signal }) => {
455
+ await api.sendEvents(events, { signal });
456
+ }),
457
+ );
458
+ ```
459
+
460
+ More: [Policies, `batch`](policies.md#batch-send-many-calls-as-one).
461
+
372
462
  ## Features and domain logic
373
463
 
374
464
  ### Get the result of emit
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`.
@@ -7,7 +7,7 @@
7
7
  | Импорт | Что внутри |
8
8
  | ------ | ---------- |
9
9
  | `nanoeffects` | `createBus`, типы |
10
- | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
10
+ | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue`, `dedupe`, `distinct`, `rateLimit`, `batch` |
11
11
  | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
12
12
 
13
13
  ## Остальные импорты
@@ -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
 
@@ -6,6 +6,30 @@
6
6
 
7
7
  Начните с `latest`. Остальные политики — в таблице и разделах ниже.
8
8
 
9
+ ## На этой странице
10
+
11
+ - [`latest`: прерывание предыдущего запуска](#latest-прерывание-предыдущего-запуска)
12
+ - [Таблица политик](#таблица-политик)
13
+ - [Справочник политик](#справочник-политик)
14
+ - [`latest`](#latest)
15
+ - [`leading`](#leading)
16
+ - [`debounce`](#debounce)
17
+ - [`throttle`](#throttle)
18
+ - [`queue`](#queue)
19
+ - [`dedupe`](#dedupe)
20
+ - [`distinct`](#distinct)
21
+ - [`rateLimit`](#ratelimit)
22
+ - [`batch`](#batch)
23
+ - [`key`](#key)
24
+ - [Настройка debounce и throttle](#настройка-debounce-и-throttle)
25
+ - [`key`: разделение state по ключу](#key-разделение-state-по-ключу)
26
+ - [Очередь событий через `queue`](#очередь-событий-через-queue)
27
+ - [`dedupe`: общий запуск для одинаковых вызовов](#dedupe-общий-запуск-для-одинаковых-вызовов)
28
+ - [`distinct`: пропуск повторного payload](#distinct-пропуск-повторного-payload)
29
+ - [`rateLimit`: соблюдение квоты](#ratelimit-соблюдение-квоты)
30
+ - [`batch`: отправка нескольких вызовов одним](#batch-отправка-нескольких-вызовов-одним)
31
+ - [`withPolicy`: вызов политики вне шины](#withpolicy-вызов-политики-вне-шины)
32
+
9
33
  ## `latest`: прерывание предыдущего запуска
10
34
 
11
35
  ```js
@@ -24,7 +48,7 @@ bus.on(
24
48
  );
25
49
  ```
26
50
 
27
- Передайте `signal` в `fetch` (и подобные вызовы), чтобы запрос отменялся. Чтобы не передавать его вообще, привяжите сигнал один раз: с `ctx.fetch` запрос останавливается вместе с обработчиком, и забыть нечего ([Сохранение сигнала в обработчике](pass-the-signal.md#привязка-сигнала-один-раз)).
51
+ Передайте `signal` в `fetch` (и подобные вызовы), чтобы запрос отменялся. Чтобы не передавать его вообще, привяжите сигнал один раз: с `signalFetch(ctx, url)` запрос останавливается вместе с обработчиком, и забыть нечего ([Сохранение сигнала в обработчике](pass-the-signal.md#привязка-сигнала-один-раз)).
28
52
 
29
53
  ## Таблица политик
30
54
 
@@ -37,6 +61,10 @@ bus.on(
37
61
  | `debounce(ms, fn, options?)` | запуск после `ms` тишины |
38
62
  | `throttle(ms, fn, options?)` | не больше одного запуска за `ms` |
39
63
  | `queue(fn, options?)` | один за другим, по порядку |
64
+ | `dedupe(fn, options?)` | вызовы с одним ключом делят идущий запуск |
65
+ | `distinct(fn, options?)` | вызов с тем же payload, что и последний, пропускается |
66
+ | `rateLimit(limit, ms, fn, options?)` | не больше `limit` запусков за `ms`, остальные ждут |
67
+ | `batch(ms, fn, options?)` | payload одного окна попадают в `fn` одним массивом |
40
68
 
41
69
  ## Справочник политик
42
70
 
@@ -82,6 +110,38 @@ bus.on(
82
110
 
83
111
  Подробнее: [Очередь событий через `queue`](#очередь-событий-через-queue).
84
112
 
113
+ ### `dedupe`
114
+
115
+ Вызовы с одним ключом, пришедшие во время запуска, ждут этот запуск и получают его результат. Ничего не кэшируется.
116
+
117
+ Сигнатура: `dedupe(fn, options?)`.
118
+
119
+ Подробнее: [`dedupe`: общий запуск для одинаковых вызовов](#dedupe-общий-запуск-для-одинаковых-вызовов).
120
+
121
+ ### `distinct`
122
+
123
+ Вызов, чей payload равен последнему, пропускается и завершается значением `undefined`.
124
+
125
+ Сигнатура: `distinct(fn, options?)`.
126
+
127
+ Подробнее: [`distinct`: пропуск повторного payload](#distinct-пропуск-повторного-payload).
128
+
129
+ ### `rateLimit`
130
+
131
+ В любом окне из `ms` начинается не больше `limit` запусков. Остальные вызовы ждут своей очереди.
132
+
133
+ Сигнатура: `rateLimit(limit, ms, fn, options?)`.
134
+
135
+ Подробнее: [`rateLimit`: соблюдение квоты](#ratelimit-соблюдение-квоты).
136
+
137
+ ### `batch`
138
+
139
+ Собирает payload, пришедшие за окно в `ms`, и вызывает `fn` один раз со всеми ними.
140
+
141
+ Сигнатура: `batch(ms, fn, options?)`.
142
+
143
+ Подробнее: [`batch`: отправка нескольких вызовов одним](#batch-отправка-нескольких-вызовов-одним).
144
+
85
145
  ### `key`
86
146
 
87
147
  Параметр каждой политики. Он разделяет state политики по признаку, например по id: вызовы с разными ключами не влияют друг на друга.
@@ -167,6 +227,134 @@ bus.emit("save", draftB); // ждёт draftA, затем выполняется
167
227
  - Когда прерывается выполняющийся вызов, его слот освобождается сразу, а результат отбрасывается.
168
228
  - Передавайте `signal` в то, что делает `fn`. `fn`, который его игнорирует, может пересечься со следующим запуском.
169
229
 
230
+ ## `dedupe`: общий запуск для одинаковых вызовов
231
+
232
+ > **Уровень B или C.** Подходит для чтения, которое несколько частей экрана запускают одновременно.
233
+
234
+ **Проблема.** Три компонента в одном такте запрашивают пользователя `1`. Без политики это три одинаковых запроса.
235
+
236
+ **Когда.** Для чтения, которое безопасно повторять: профиль, конфиг, список по id. Для записи используйте [`leading`](prevent-double-submit.md) или [`queue`](#очередь-событий-через-queue): см. правило прерывания ниже.
237
+
238
+ `dedupe` запускает `fn` для первого вызова. Вызов с тем же ключом, пришедший, пока идёт этот запуск, ждёт его и получает его результат или его ошибку. Когда запуск закончился, `dedupe` его забывает, и следующий вызов запускает `fn` заново. Это не кэш: старое значение он не вернёт никогда.
239
+
240
+ ```js
241
+ import { dedupe, withPolicy } from "nanoeffects/policies";
242
+
243
+ // dedupe — вызовы с одним id делят один запрос
244
+ const policy = dedupe((id, { signal }) => api.getUser(id, { signal }));
245
+
246
+ // withPolicy — обычная функция, поэтому пример показывает результат каждого вызова
247
+ const loadUser = withPolicy(policy);
248
+
249
+ // два вызова, один запрос: оба получают одного пользователя
250
+ const [first, second] = await Promise.all([loadUser(1), loadUser(1)]);
251
+ ```
252
+
253
+ На шине выглядит так же: `bus.on("user/load", dedupe(handler))`. Чтобы получить результат `emit`, используйте action или job ([Actions](actions.md)).
254
+
255
+ | Параметр | Значение |
256
+ | -------- | -------- |
257
+ | `key` | что делает два вызова «одинаковыми». По умолчанию сам payload, сравнивается как ключ `Map`. Объекты не совпадают, пока `key` не скажет как. `key: () => "all"` даёт один общий запуск на все вызовы |
258
+
259
+ **Прерывание:**
260
+
261
+ - Вызов, чья задача прервана, завершается значением `undefined`. Остальные продолжают.
262
+ - Запуск принадлежит задаче первого вызова. Если эта задача прерывается, пока другие ещё ждут, запуск прерывается и начинается заново со следующим вызовом, поэтому `fn` может выполниться дважды для одного запроса. Для чтения это нормально, для записи — нет.
263
+ - Передавайте `signal` в то, что делает `fn`, как для любой политики.
264
+
265
+ ## `distinct`: пропуск повторного payload
266
+
267
+ > **Уровень B.** Подходит, когда одно и то же значение приходит снова и снова, а повторять работу не нужно.
268
+
269
+ **Проблема.** Форма сохраняется при каждом изменении. Пользователь набрал букву и стёр её, и тот же текст снова уходит на сервер.
270
+
271
+ `distinct` сравнивает payload каждого вызова с payload последнего пропущенного вызова. Равный payload пропускается: `fn` не запускается, а вызов завершается значением `undefined`. Политика помнит один payload, но не результат.
272
+
273
+ ```js
274
+ import { distinct, withPolicy } from "nanoeffects/policies";
275
+
276
+ // distinct — запускается, только если текст отличается от последнего пропущенного
277
+ const policy = distinct(async (text) => {
278
+ await api.save(text);
279
+ });
280
+ const save = withPolicy(policy);
281
+
282
+ await save("hello"); // выполняется
283
+ await save("hello"); // пропущен, завершается значением undefined
284
+ await save("hello!"); // выполняется: текст изменился
285
+ ```
286
+
287
+ | Параметр | Значение |
288
+ | -------- | -------- |
289
+ | `equals(previous, next)` | говорит, одинаковы ли два payload. По умолчанию `Object.is` |
290
+ | `key` | каждый ключ помнит свой payload |
291
+
292
+ - Payload считается увиденным с момента начала его запуска, поэтому такой же payload, пришедший во время запуска, тоже пропускается.
293
+ - Он забывается, когда запуск завершился ошибкой или его задача прервана, поэтому повтор с тем же payload выполнится.
294
+ - Память ключа остаётся после запуска. В отличие от других политик, `distinct` не удаляет простаивающие ключи, поэтому давайте `key` значения из ограниченного набора (id формы, а не id запроса).
295
+
296
+ ## `rateLimit`: соблюдение квоты
297
+
298
+ > **Уровень C.** Подходит, когда API разрешает N запросов за период.
299
+
300
+ **Проблема.** API геокодирования разрешает 5 запросов в секунду. Список из 40 адресов отправляет 40 запросов сразу, и большинство падает с `429`.
301
+
302
+ `rateLimit(limit, ms, fn)` разрешает начаться не больше чем `limit` запускам в любом окне из `ms` миллисекунд. Окно скользящее: оно отсчитывается назад от каждого вызова. Вызов сверх лимита ждёт и начинается по порядку, когда самый старый старт выходит из окна.
303
+
304
+ ```js
305
+ import { rateLimit, withPolicy } from "nanoeffects/policies";
306
+
307
+ // rateLimit — за секунду могут начаться 5 запусков, остальные ждут своей очереди
308
+ const policy = rateLimit(5, 1000, (address, { signal }) => {
309
+ return api.geocode(address, { signal });
310
+ });
311
+ const geocode = withPolicy(policy);
312
+
313
+ const addresses = ["10 Main St", "22 Oak Ave", "5 Pine Rd", "8 Elm St", "3 Lake Dr", "9 Hill Ln"];
314
+
315
+ // первые пять начинаются сразу, шестой — через секунду
316
+ const results = await Promise.all(addresses.map(geocode));
317
+ ```
318
+
319
+ | Параметр | Значение |
320
+ | -------- | -------- |
321
+ | `overflow` | `"wait"` (по умолчанию) держит вызов, пока он не сможет начаться. `"drop"` отбрасывает вызов, который застал окно полным: он завершается значением `undefined` |
322
+ | `key` | у каждого ключа своё окно, например по одному на пользователя |
323
+
324
+ - Политика ограничивает **старты**, а не число одновременных запусков, и не ждёт окончания запуска. Для обоих ограничений напишите `queue(rateLimit(5, 1000, fn), { concurrency: 2 })`: вызов держит свой слот в очереди, пока ждёт окна.
325
+ - Вызов, чья задача прервана во время ожидания, выходит из очереди. Прерывание уже начавшегося запуска не возвращает его место в окне: запрос уже отправлен.
326
+ - `limit` меньше 1 считается равным 1.
327
+
328
+ ## `batch`: отправка нескольких вызовов одним
329
+
330
+ > **Уровень C.** Подходит, когда работа дешевле оптом: события для аналитики, строки лога, запрос «дай эти id».
331
+
332
+ **Проблема.** Страница отправляет событие аналитики на каждый клик, 30 запросов в минуту. Серверу удобнее один запрос со всеми.
333
+
334
+ `batch(ms, fn)` открывает окно в `ms` на первом вызове и собирает payload, пришедшие за это время. Когда окно закрывается, `fn` запускается один раз с массивом из них, в порядке вызовов. Окно не сдвигается, когда приходят новые вызовы, поэтому постоянный поток отправляется каждые `ms`, а не только когда он затих.
335
+
336
+ ```js
337
+ import { batch, withPolicy } from "nanoeffects/policies";
338
+
339
+ // batch — fn получает массив всего, что пришло за 500 мс
340
+ const policy = batch(500, async (events) => {
341
+ await api.sendEvents(events);
342
+ });
343
+ const track = withPolicy(policy);
344
+
345
+ track({ name: "open" });
346
+ track({ name: "click" }); // один запрос с обоими событиями, через 500 мс после первого
347
+ ```
348
+
349
+ | Параметр | Значение |
350
+ | -------- | -------- |
351
+ | `maxSize` | отправить пакет сразу, когда в нём набралось столько payload |
352
+ | `key` | каждый ключ собирает свой пакет, например по одному на пользователя |
353
+
354
+ - Каждый вызов завершается тем, что `fn` вернул для его пакета: одно и то же значение для всех или одна и та же ошибка. `batch` не делит результат-массив между вызовами.
355
+ - Вызов, чья задача прервана, пока пакет собирается, выходит из него и завершается значением `undefined`. Когда вышли все вызовы, ничего не запускается.
356
+ - `fn` получает контекст первого вызова, который ещё ждёт. Если эта задача прерывается, пока идёт `fn`, запуск прерывается, и все вызовы этого пакета завершаются значением `undefined`. Запуск не повторяется: часть массового вызова могла уже выполниться.
357
+
170
358
  ## `withPolicy`: вызов политики вне шины
171
359
 
172
360
  **Проблема.** Нужен обработчик ввода с debounce или запрос по принципу «побеждает последний» для вашей собственной функции, но шины в коде нет: DOM-обработчик, callback клика, функция в сервисе. Политике на каждом вызове нужен контекст, поэтому `debounce(300, search)("re")` сам по себе не работает.
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
  - [Проблемы формы без шины](#проблемы-формы-без-шины)