nanoeffects 0.29.0 → 0.31.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 (127) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-jeEqIKgK.d.ts → action-creator-6rlBTmH9.d.ts} +27 -2
  3. package/dist/actions.d.ts +3 -3
  4. package/dist/actions.js +1 -1
  5. package/dist/{bus-0uYn_kRj.js → bus-DFlmNJ6h.js} +1 -1
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/{context-types-Bme1mzkn.d.ts → context-types-DKNc5mkS.d.ts} +1 -1
  8. package/dist/context.d.ts +2 -2
  9. package/dist/dev.d.ts +49 -3
  10. package/dist/dev.js +2 -1
  11. package/dist/effects.d.ts +2 -2
  12. package/dist/effects.js +1 -1
  13. package/dist/emit-key-C6FL7Ewn.js +1 -0
  14. package/dist/guard.d.ts +59 -0
  15. package/dist/guard.js +1 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/match.d.ts +1 -1
  19. package/dist/{module-BWMW83qk.d.ts → module-G39MaVOt.d.ts} +2 -2
  20. package/dist/notrace/{action-creator-xpQ_I2h8.d.ts → action-creator-BrMSO3RP.d.ts} +1 -0
  21. package/dist/notrace/actions.d.ts +2 -2
  22. package/dist/notrace/effects.d.ts +2 -2
  23. package/dist/notrace/index.d.ts +1 -1
  24. package/dist/notrace/{plugin-6LhNx0th.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  25. package/dist/notrace/policies.d.ts +1 -1
  26. package/dist/notrace/redux.d.ts +1 -1
  27. package/dist/observable.d.ts +1 -1
  28. package/dist/oop.d.ts +1 -1
  29. package/dist/oop.js +1 -1
  30. package/dist/{plugin-BaErevLy.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  31. package/dist/policies.d.ts +2 -2
  32. package/dist/policies.js +1 -1
  33. package/dist/production/dev.js +1 -1
  34. package/dist/react.d.ts +2 -2
  35. package/dist/react.js +1 -1
  36. package/dist/redux.d.ts +1 -1
  37. package/dist/scope.d.ts +2 -2
  38. package/dist/signal.d.ts +22 -2
  39. package/dist/signal.js +1 -1
  40. package/dist/site-DLvUoXs3.js +1 -0
  41. package/dist/svelte.d.ts +1 -1
  42. package/dist/trace.d.ts +151 -2
  43. package/dist/trace.js +3 -1
  44. package/dist/{types-D5xsR37j.d.ts → types-N_cdRr1r.d.ts} +2 -2
  45. package/dist/vue.d.ts +1 -1
  46. package/dist/{with-policy-C6BIiPIi.js → with-policy-CeM1rtES.js} +1 -1
  47. package/dist/{with-policy-BZVRBUOm.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  48. package/docs/README.md +62 -31
  49. package/docs/actions.md +44 -27
  50. package/docs/cancel-stale-requests.md +2 -0
  51. package/docs/chat-example.md +3 -1
  52. package/docs/chat-widget.md +2 -0
  53. package/docs/context.md +322 -9
  54. package/docs/debounce-and-abort.md +3 -1
  55. package/docs/debugging-loops.md +318 -0
  56. package/docs/effects.md +255 -138
  57. package/docs/errors.md +4 -2
  58. package/docs/examples.md +60 -0
  59. package/docs/guide.md +39 -22
  60. package/docs/hmr.md +35 -1
  61. package/docs/loop-guard.md +109 -0
  62. package/docs/migrating-from-effector.md +4 -2
  63. package/docs/migrating-from-mitt.md +6 -4
  64. package/docs/migrating-from-rxjs.md +2 -0
  65. package/docs/migrating-from-saga.md +2 -0
  66. package/docs/motivation.md +14 -21
  67. package/docs/observable.md +2 -0
  68. package/docs/oop.md +3 -1
  69. package/docs/package.md +4 -1
  70. package/docs/pass-the-signal.md +346 -0
  71. package/docs/plugins.md +22 -5
  72. package/docs/policies.md +73 -46
  73. package/docs/prevent-double-submit.md +3 -1
  74. package/docs/query-libraries.md +3 -1
  75. package/docs/react.md +42 -13
  76. package/docs/recipes.md +528 -0
  77. package/docs/ru/README.md +62 -0
  78. package/docs/ru/actions.md +412 -0
  79. package/docs/ru/cancel-stale-requests.md +106 -0
  80. package/docs/ru/chat-example.md +259 -0
  81. package/docs/ru/chat-widget.md +131 -0
  82. package/docs/ru/context.md +402 -0
  83. package/docs/ru/debounce-and-abort.md +105 -0
  84. package/docs/ru/debugging-loops.md +318 -0
  85. package/docs/ru/effects.md +538 -0
  86. package/docs/ru/errors.md +36 -0
  87. package/docs/ru/examples.md +60 -0
  88. package/docs/ru/guide.md +530 -0
  89. package/docs/ru/hmr.md +83 -0
  90. package/docs/ru/loop-guard.md +109 -0
  91. package/docs/ru/migrating-from-effector.md +339 -0
  92. package/docs/ru/migrating-from-mitt.md +99 -0
  93. package/docs/ru/migrating-from-rxjs.md +197 -0
  94. package/docs/ru/migrating-from-saga.md +97 -0
  95. package/docs/ru/motivation.md +47 -0
  96. package/docs/ru/observable.md +233 -0
  97. package/docs/ru/oop.md +431 -0
  98. package/docs/ru/package.md +42 -0
  99. package/docs/ru/pass-the-signal.md +346 -0
  100. package/docs/ru/plugins.md +398 -0
  101. package/docs/ru/policies.md +201 -0
  102. package/docs/ru/prevent-double-submit.md +122 -0
  103. package/docs/ru/query-libraries.md +392 -0
  104. package/docs/ru/react.md +309 -0
  105. package/docs/ru/recipes.md +528 -0
  106. package/docs/ru/semantics.md +19 -0
  107. package/docs/ru/server.md +204 -0
  108. package/docs/ru/starter.md +118 -0
  109. package/docs/ru/stores.md +673 -0
  110. package/docs/ru/svelte.md +78 -0
  111. package/docs/ru/tracing.md +466 -0
  112. package/docs/ru/typescript.md +170 -0
  113. package/docs/ru/vue.md +92 -0
  114. package/docs/semantics.md +2 -0
  115. package/docs/server.md +2 -0
  116. package/docs/starter.md +2 -0
  117. package/docs/stores.md +2 -0
  118. package/docs/svelte.md +3 -1
  119. package/docs/tracing.md +58 -1
  120. package/docs/typescript.md +2 -0
  121. package/docs/vue.md +3 -1
  122. package/llms-full.txt +2087 -418
  123. package/llms.txt +11 -8
  124. package/package.json +23 -6
  125. package/dist/context-DtUnzC-1.js +0 -1
  126. package/docs/scope.md +0 -278
  127. package/docs/stability.md +0 -90
@@ -0,0 +1,346 @@
1
+ # Сохранение сигнала в обработчике
2
+
3
+ > **[Уровень B](README.md#виды-проектов)**: SPA с async UI. Сначала прочитайте: [Политики](policies.md#latest).
4
+
5
+ Политика вроде `latest` отменяет старый вызов, прерывая его `ctx.signal`. Обработчик останавливается, только если сигнал получил каждый шаг, который чего-то ждёт. Один запрос без сигнала продолжает работу, и его результат приходит позже нового. На этой странице перечислены способы потерять сигнал, что находит каждый из них до запуска кода (линтер) и во время работы (`devPlugin`), и как это исправить.
6
+
7
+ ## На этой странице
8
+
9
+ - [Что делает сигнал](#что-делает-сигнал)
10
+ - [Привязка сигнала один раз](#привязка-сигнала-один-раз)
11
+ - [Исправление обработчика, который теряет сигнал](#исправление-обработчика-который-теряет-сигнал)
12
+ - [Передача сигнала в сложных случаях](#передача-сигнала-в-сложных-случаях)
13
+ - [Включение проверок](#включение-проверок)
14
+ - [Выбор проверяемых вызовов](#выбор-проверяемых-вызовов)
15
+ - [Подсчёт потерянных сигналов в тесте](#подсчёт-потерянных-сигналов-в-тесте)
16
+ - [Проверка тестом](#проверка-тестом)
17
+ - [Что проверки не видят](#что-проверки-не-видят)
18
+
19
+ ## Что делает сигнал
20
+
21
+ `ctx.signal` — это `AbortSignal`. Политика прерывает его, когда вызов отменяют (новый вызов `latest`, замена в `debounce`, размонтированный модуль). Шаг останавливается, когда вы передаёте ему сигнал: `fetch(url, { signal: ctx.signal })`, `api.search(query, { signal: ctx.signal })` или весь `ctx` для эффекта, `retry(ctx, ...)`. Всё остальное продолжает работать. Шина отбрасывает то, что отменённый обработчик отправляет через `ctx.emit`, поэтому потеря проявляется поздно и тихо.
22
+
23
+ ## Привязка сигнала один раз
24
+
25
+ Надёжнее всего обработчик, в котором сигнала нет вообще. Создайте клиент из сигнала задачи один раз, и обработчик вызывает `ctx.fetch`, ничего не забывая. `signalFetch` — это `fetch`, привязанный к сигналу; `fromSignal` создаёт его для каждой задачи.
26
+
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";
33
+
34
+ // fromSignal — ctx.fetch создаётся из сигнала задачи, которая его читает
35
+ export const bus = createBus().use(
36
+ contextPlugin({
37
+ fetch: fromSignal(signalFetch),
38
+ // и ваш клиент тоже: он создаётся из сигнала каждой задачи
39
+ http: fromSignal((signal) => axios.create({ signal })),
40
+ }),
41
+ );
42
+ ```
43
+
44
+ ```js
45
+ // search-handlers.js
46
+ import { latest } from "nanoeffects/policies";
47
+ import { bus } from "./bus";
48
+
49
+ bus.on(
50
+ "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());
55
+ }),
56
+ );
57
+ ```
58
+
59
+ Вызов со своим `signal` останавливается тем сигналом, который прервётся раньше. Затем пусть линтер держит линию: с `requireContext: true` каждый вызов, которого ждёт обработчик, должен идти через `ctx` или получать `ctx`, поэтому глобальный `fetch` или `api.search(...)` — ошибка, даже если сигнал передан вручную ([включение проверок](#включение-проверок)).
60
+
61
+ ## Исправление обработчика, который теряет сигнал
62
+
63
+ Две формы, которые встречаются первыми, и исправление:
64
+
65
+ ```js
66
+ import { createBus } from "nanoeffects";
67
+ import { latest } from "nanoeffects/policies";
68
+
69
+ const bus = createBus();
70
+
71
+ // 1. ctx взят, а сигнал потерян: запрос не может остановиться
72
+ bus.on(
73
+ "search",
74
+ latest(async (query, ctx) => {
75
+ const items = await api.search(query);
76
+ ctx.emit("results", items);
77
+ }),
78
+ );
79
+
80
+ // 2. один шаг передаёт сигнал, другой нет: первый не может остановиться
81
+ bus.on(
82
+ "feed",
83
+ latest(async (query, ctx) => {
84
+ const news = await api.news(query);
85
+ const posts = await api.posts(query, { signal: ctx.signal });
86
+ ctx.emit("results", { news, posts });
87
+ }),
88
+ );
89
+
90
+ // исправление: сигнал получает каждый шаг, который чего-то ждёт
91
+ bus.on(
92
+ "feed/fixed",
93
+ latest(async (query, ctx) => {
94
+ const news = await api.news(query, { signal: ctx.signal });
95
+ const posts = await api.posts(query, { signal: ctx.signal });
96
+ ctx.emit("results", { news, posts });
97
+ }),
98
+ );
99
+ ```
100
+
101
+ ## Передача сигнала в сложных случаях
102
+
103
+ Каждая строка — ситуация, где сигнал легко потерять. В правой колонке сказано, как его сохранить. «Линтер» — правило из [`examples/lint`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/lint), которое это находит, а «devPlugin» — предупреждение во время работы ([подробнее](hmr.md#поиск-обработчика-который-игнорирует-сигнал)).
104
+
105
+ | Ситуация | Что сделать | Линтер | devPlugin |
106
+ | -------- | ----------- | :----: | :-------: |
107
+ | У обработчика нет `ctx`: `latest(async (query) => ...)` | Возьмите `(query, ctx)` и передайте `ctx.signal` | да | |
108
+ | `ctx` взят, но в запрос не передан | Передайте `{ signal: ctx.signal }` в каждый запрос или привяжите его один раз: `ctx.fetch` ([выше](#привязка-сигнала-один-раз)) | да | да |
109
+ | Один шаг передаёт сигнал, другой нет | Передайте его каждому шагу | да | да |
110
+ | Сигнал лежит в переменной: `const options = { signal: ctx.signal }` | Ничего: переменная с сигналом считается | принимает | |
111
+ | Вызовы идут вместе: `Promise.all([a(), b()])` | Передайте сигнал каждому вызову, а не одному | да | |
112
+ | `Promise.all(ids.map((id) => load(id)))` | Передайте сигнал внутри callback | да | |
113
+ | Шаг внутри эффекта: `retry(ctx, () => api.search(q))` | Callback не получает сигнал. Передайте `{ signal: ctx.signal }` в нём или используйте дочерний контекст, который он получает | да | |
114
+ | `timeout(ctx, 1000, (child) => api.search(q))` | Передайте в запрос `child` или `child.signal` | да | |
115
+ | `ctx.run((child) => api.search(q))` | Передайте `child` | да | |
116
+ | Пауза: `await new Promise((r) => setTimeout(r, 1000))` | Используйте `delay(ctx, 1000)` | да | |
117
+ | Поток: `for await (const x of api.stream(q))` | Передайте сигнал в `api.stream` | да | |
118
+ | Цепочка: `api.news(q).then(...)` | Передайте сигнал первому вызову | да | |
119
+ | Чужой сигнал: `AbortSignal.timeout(5000)` или сигнал вашего `AbortController` | Объедините его с сигналом обработчика: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])` | да | |
120
+ | Чтение тела ответа: `await response.json()` | Ничего: сигнал запроса останавливает и его | принимает | |
121
+ | Библиотека, которая не принимает сигнал (кэш, локальная база) | Укажите её в `ignore` или проверяйте только вызовы из своего списка через `only`. С информацией о типах правило само пропускает функцию без сигнала | `ignore`, `only`, типы | |
122
+ | Функция принимает сигнал и не использует его: `search(query, options) { return fetch(url); }` | Передайте сигнал дальше внутри неё. В линтере это находит только информация о типах; тест находит наверняка | типы | |
123
+ | Сигнал подключён вручную: `signal.addEventListener("abort", ...)` | Ничего: такой обработчик не проверяется по шагам | принимает | |
124
+ | `bus.emit` в обработчике | Используйте `ctx.emit`: только его отбрасывают после отмены обработчика | да | |
125
+ | Запрос продолжает идти после отмены, на Node | Передайте сигнал или используйте `ctx.fetch` | | да |
126
+ | Результаты отправлены после отмены | Обработчик проигнорировал сигнал. Передайте его или проверьте `ctx.signal.aborted` | | да |
127
+ | `ctx.on` после отмены | То же | | да |
128
+
129
+ Строки про паузу и эффекты в коде:
130
+
131
+ ```js
132
+ import { createBus } from "nanoeffects";
133
+ import { delay, retry, timeout } from "nanoeffects/effects";
134
+ import { latest } from "nanoeffects/policies";
135
+
136
+ const bus = createBus();
137
+
138
+ bus.on(
139
+ "search",
140
+ latest(async (query, ctx) => {
141
+ // delay — пауза, которая останавливается вместе с ctx, в отличие от new Promise(setTimeout)
142
+ await delay(ctx, 300);
143
+
144
+ // retry — попытки останавливаются вместе с ctx, но запросу тоже нужен сигнал
145
+ const items = await retry(ctx, () => api.search(query, { signal: ctx.signal }));
146
+
147
+ // timeout — callback получает дочерний контекст: передайте его запросу
148
+ const details = await timeout(ctx, 5000, (child) => api.details(query, child));
149
+
150
+ ctx.emit("results", { items, details });
151
+ }),
152
+ );
153
+ ```
154
+
155
+ Вызовы, которые идут вместе, и сигнал, который объединяется с другим:
156
+
157
+ ```js
158
+ import { createBus } from "nanoeffects";
159
+ import { latest } from "nanoeffects/policies";
160
+
161
+ const bus = createBus();
162
+
163
+ bus.on(
164
+ "feed",
165
+ latest(async (query, ctx) => {
166
+ // каждый вызов группы получает сигнал, а не только первый
167
+ const [news, posts] = await Promise.all([
168
+ api.news(query, { signal: ctx.signal }),
169
+ api.posts(query, { signal: ctx.signal }),
170
+ ]);
171
+
172
+ // AbortSignal.any — останавливается вместе с обработчиком или через 5 секунд, что наступит раньше
173
+ const signal = AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)]);
174
+ const details = await api.details(query, { signal });
175
+
176
+ ctx.emit("results", { news, posts, details });
177
+ }),
178
+ );
179
+ ```
180
+
181
+ ## Включение проверок
182
+
183
+ **Пока вы пишете код.** В [`examples/lint`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/lint) есть правило для ESLint и oxlint (один файл, без зависимостей) и два плагина GritQL для Biome. Скопируйте файл, включите правило и передайте ему имена вызовов, которые нельзя отменить:
184
+
185
+ <!-- docs-check: skip - файл настройки проекта читателя -->
186
+ ```js
187
+ // eslint.config.mjs
188
+ import nanoeffects from "./nanoeffects-lint.mjs";
189
+
190
+ export default [
191
+ {
192
+ files: ["**/*.{js,mjs,cjs,jsx,ts,tsx}"],
193
+ plugins: { nanoeffects },
194
+ rules: {
195
+ "nanoeffects/policy-handler-uses-signal": [
196
+ "warn",
197
+ // вызовы без сигнала, которых можно просто дождаться
198
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
199
+ ],
200
+ },
201
+ },
202
+ ];
203
+ ```
204
+
205
+ Для oxlint перечислите файл в `jsPlugins` файла `.oxlintrc.json` и используйте то же имя правила. Для Biome перечислите два файла `.grit` в `plugins`. Плагины Biome читают текст, поэтому находят прямые шаги (первые строки таблицы) и пропускают более глубокие: вызовы `Promise.all`, callback эффектов, `for await`, паузу и ключ объекта с параметрами.
206
+
207
+ **Пока код работает.** `devPlugin` один раз предупреждает, когда обработчик отправляет событие или подписывается через `ctx.on` после того, как его задачу отменили:
208
+
209
+ ```js
210
+ import { createBus } from "nanoeffects";
211
+ import { devPlugin } from "nanoeffects/dev";
212
+
213
+ const bus = createBus();
214
+
215
+ // devPlugin — предупреждает об обработчике, который продолжил работу после отмены; в production ничего не делает
216
+ bus.use(devPlugin());
217
+ ```
218
+
219
+ ## Выбор проверяемых вызовов
220
+
221
+ Правило не может понять по одному вызову, принимает ли функция сигнал. Есть несколько способов это ему сказать:
222
+
223
+ - **`requireContext`** списка не требует: включите `true`, если обработчики делают запросы через `ctx` (см. [Привязка сигнала один раз](#привязка-сигнала-один-раз)), и любой вызов не через `ctx` попадёт в отчёт. `ignore` по-прежнему пропускает вызов.
224
+ - **`ignore`** перечисляет вызовы, которые нужно пропустить, по имени: `ignore: ["cache.get", "storage.*"]`. Подходит, когда большинство вызовов принимает сигнал, а несколько не могут.
225
+ - **`only`** перечисляет вызовы, которые нужно проверять, по имени: `only: ["api.*", "fetch"]`. Всё остальное правило не трогает, так что вызов не из списка никогда не попадёт в отчёт. Подходит, когда вы знаете, какие клиенты принимают сигнал. Вызов из обоих списков пропускается, а пауза, которую нельзя отменить (`await new Promise(...)`), попадает в отчёт независимо от `only`.
226
+ - **Информация о типах** списков не требует. С `@typescript-eslint/parser` и `project` правило читает вызываемую функцию. Вызов без сигнала попадает в отчёт, только если функция его принимает (параметр типа `AbortSignal` или объект параметров с `signal`), поэтому `cache.get(key)` правило не трогает. Вызов с сигналом попадает в отчёт, если функция лежит в ваших исходниках и не использует сигнал: параметр не используется или из него читаются только другие параметры. Сигнал, переданный в другую вашу функцию, правило прослеживает до трёх вызовов вглубь, а функцию из библиотеки считает надёжной. Вызываемое с типом `any` попадает в отчёт, как раньше.
227
+
228
+ <!-- docs-check: skip - файл настройки проекта читателя -->
229
+ ```js
230
+ // eslint.config.mjs
231
+ import parser from "@typescript-eslint/parser";
232
+ import nanoeffects from "./nanoeffects-lint.mjs";
233
+
234
+ export default [
235
+ {
236
+ files: ["**/*.{ts,tsx}"],
237
+ // парсер даёт правилу проверку типов вашего проекта
238
+ languageOptions: { parser, parserOptions: { project: "./tsconfig.json" } },
239
+ plugins: { nanoeffects },
240
+ rules: { "nanoeffects/policy-handler-uses-signal": "warn" },
241
+ },
242
+ ];
243
+ ```
244
+
245
+ oxlint и Biome не дают правилу информацию о типах, поэтому они работают по синтаксису и спискам. Режиму с типами нужен пакет `typescript` с API компилятора (сегодня это версия 5; у нативной версии 7 такого API нет), как и `@typescript-eslint/parser`.
246
+
247
+ ## Подсчёт потерянных сигналов в тесте
248
+
249
+ `getLostSignalsCount(bus)` из `nanoeffects/dev` возвращает, сколько сигналов потеряли обработчики шины: каждый emit или `ctx.on`, который обработчик сделал после отмены своей задачи. Шине нужен `devPlugin`. Запустите сценарий так, как это сделал бы пользователь, отмените его и проверьте, что счётчик равен 0:
250
+
251
+ ```js
252
+ import { createBus } from "nanoeffects";
253
+ import { devPlugin, getLostSignalsCount, getLostSignals } from "nanoeffects/dev";
254
+ import { latest } from "nanoeffects/policies";
255
+ import { expect, test } from "vitest";
256
+
257
+ test("a newer search leaves no lost signals", async () => {
258
+ const bus = createBus();
259
+ // devPlugin — записывает каждый emit отменённого обработчика; log прячет его предупреждения в тесте
260
+ bus.use(devPlugin({ log: () => {} }));
261
+ bus.on(
262
+ "search",
263
+ latest(async (query, ctx) => {
264
+ const items = await api.search(query, { signal: ctx.signal });
265
+ ctx.emit("results", items);
266
+ }),
267
+ );
268
+
269
+ bus.emit("search", "a");
270
+ bus.emit("search", "ab"); // отменяет первый вызов
271
+
272
+ // wait — сколько мс ждать, пока обработчики закончат, прежде чем считать
273
+ const count = await getLostSignalsCount(bus, { wait: 50 });
274
+
275
+ expect(count).toBe(0);
276
+ });
277
+ ```
278
+
279
+ `getLostSignals(bus)` возвращает не число, а список: `{ kind: "emit" | "on", handler, event }` для каждого, так что упавший тест называет обработчик и событие. Без `devPlugin` (или с `abandoned: false`) функция бросает ошибку и говорит об этом. В production-сборке обе функции пусты: счётчик равен 0.
280
+
281
+ На Node (тесты, серверы) считаются и запросы. `devPlugin` следит за вызовами `fetch`, которые делает обработчик, в том числе после `await`, и записывает запрос как `kind: "request"`, если его задачу отменили, пока запрос ещё шёл, и запрос не остановился: у него не было сигнала или сигнал не принадлежит задаче. В тест добавлять ничего не нужно. В браузере узнать, какая задача сделала запрос, нельзя, поэтому там считаются только emit и подписки. `requests: false` отключает слежение.
282
+
283
+ ```js
284
+ import { createBus } from "nanoeffects";
285
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
286
+ import { latest } from "nanoeffects/policies";
287
+
288
+ const bus = createBus();
289
+ bus.use(devPlugin({ log: () => {} }));
290
+
291
+ bus.on(
292
+ "search",
293
+ latest(async (query) => {
294
+ await fetch(`/api?q=${query}`); // сигнала нет
295
+ }),
296
+ );
297
+
298
+ bus.emit("search", "a");
299
+ bus.emit("search", "ab");
300
+
301
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
302
+ console.log(await getLostSignals(bus, { wait: 50 }));
303
+ ```
304
+
305
+ Запрос, который закончился до отмены, не считается. Другого клиента (axios, драйвер) привяжите через `fromSignal` или подмените в тесте, как следующий раздел делает для `fetch`.
306
+
307
+ ## Проверка тестом
308
+
309
+ Линтер читает код и не знает, что функция делает с сигналом. Тест знает. Идея: подменить `fetch` сервером, который отвечает, когда тест это разрешает, и останавливается, когда сигнал запроса прерван, запустить обработчик, отменить его посреди каждого шага и перечислить запросы, которые не остановились. Таймеров нет, поэтому результат не зависит от скорости машины.
310
+
311
+ <!-- docs-check: skip - тестовый файл проекта читателя -->
312
+ ```js
313
+ // search.cancel.test.js
314
+ import { createBus } from "nanoeffects";
315
+ import { latest } from "nanoeffects/policies";
316
+ import { expect, test } from "vitest";
317
+ // скопируйте prove-cancel.js из examples/lint; пакеты ему не нужны
318
+ import { requestsThatKeptRunning } from "./prove-cancel.js";
319
+
320
+ test("a newer search stops the older one at every step", async () => {
321
+ const bus = createBus();
322
+ bus.on(
323
+ "search",
324
+ latest(async (query, ctx) => {
325
+ const news = await api.news(query, { signal: ctx.signal });
326
+ const posts = await api.posts(query, { signal: ctx.signal });
327
+ ctx.emit("results", { news, posts });
328
+ }),
329
+ );
330
+
331
+ // для каждого шага: запустить вызов, отменить его новым и собрать запросы, которые не остановились
332
+ const kept = await requestsThatKeptRunning((value) => bus.emit("search", value));
333
+
334
+ // пусто, если каждый запрос получил сигнал, который прервался вместе с обработчиком
335
+ expect(kept).toEqual([]);
336
+ });
337
+ ```
338
+
339
+ В `kept` лежит url каждого запроса, который продолжил работу, шаг, на котором отменили обработчик, и признак того, был ли у запроса сигнал вообще (`hadSignal: false`) или не сигнал обработчика (`hadSignal: true`). Так находится то, чего не видит линтер: `api.posts` принимает `{ signal }` и игнорирует его, `AbortSignal.timeout` вместо сигнала обработчика, запрос в ветке, о которой вы не подумали. Тест проверяет границу, `fetch`; для другого клиента (axios, драйвер базы) подмените этого клиента так же. В репозитории помощник проверен на обработчиках, которые передают сигнал, игнорируют его, теряют на третьем шаге и используют собственный сигнал.
340
+
341
+ ## Что проверки не видят
342
+
343
+ - **Обработчик, записанный не на месте.** Линтер не читает `latest(searchHandler)` и `latest(withLogging(async (query, ctx) => ...))`.
344
+ - **Вызов, который игнорирует переданный сигнал.** Без информации о типах правило видит, что вы его передали, но не знает, что с ним делает вызываемый код. С ней правило читает вызываемое в ваших исходниках, а библиотеку считает надёжной. Тест выше проверяет настоящие запросы.
345
+ - **Обработчик, который ничего не отправляет.** `devPlugin` замечает потерянный сигнал по emit после отмены; обработчик, который пишет данные в другое место, видит только линтер.
346
+ - **Затенённые имена.** Правило не анализирует области видимости, поэтому внутренняя переменная `signal` может скрыть потерю сигнала.