nanoeffects 0.31.0 → 0.31.1

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/dev.js CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./emit-key-C6FL7Ewn.js";import{t}from"./event-DVcW0mME.js";import{t as n}from"./site-DLvUoXs3.js";import{t as r}from"./mount-each-D54UqAhZ.js";const i=()=>{let e=globalThis.process?.getBuiltinModule,t=e?.(`node:async_hooks`)?.AsyncLocalStorage;return t?new t:null},a=e=>typeof e==`string`?e:e instanceof URL?e.href:String(Reflect.get(Object(e),`url`)),o=(e,t)=>t?.signal?t.signal:typeof Request==`function`&&e instanceof Request?e.signal:void 0,s=(e,t,n)=>{let r=i(),s=globalThis.fetch;if(!r||typeof s!=`function`)return null;let c=/* @__PURE__ */ new Set,l=/* @__PURE__ */ new WeakMap,u=e=>{for(let r of l.get(e)??[]){if(r.counted||r.settled||r.signal?.aborted)continue;r.counted=!0,t.push({kind:`request`,handler:e.type,event:r.url});let i=`${e.type}\u0000${r.url}`;c.has(i)||(c.add(i),n(`[nanoeffects] the handler of "${e.type}" has a request to "${r.url}" that kept running after its task was aborted. Pass ctx.signal to it (fetch(url, { signal: ctx.signal })), or make it through a client bound with fromSignal.`))}},d=(e,t,n,r)=>{let i={url:a(t),signal:o(t,n),settled:!1,counted:!1},s=()=>{i.settled=!0};r.then(s,s);let c=()=>u(e),d=l.get(e);d?d.push(i):(l.set(e,[i]),e.signal.addEventListener(`abort`,()=>queueMicrotask(c),{once:!0})),e.signal.aborted&&queueMicrotask(c)},f=(e,t)=>{let n=r.getStore(),i=s(e,t);return n&&d(n,e,t,i),i};globalThis.fetch=f;let p=e.emit,m=(...e)=>{let t=r.getStore();try{return p(...e)}finally{r.enterWith(t)}};return e.emit=m,{enter(e){r.enterWith(e);let t=e.run;e.run=(...e)=>{let n=r.getStore();try{return t(...e)}finally{r.enterWith(n)}}},stop(){globalThis.fetch===f&&(globalThis.fetch=s),e.emit===m&&(e.emit=p)}}},c=`[nanoeffects]`,l=(e={})=>t=>{let{maxListeners:n=10,leaks:r=!0,modules:i=!0,abandoned:a=!0,requests:o=!0,app:s}=e,c=e.log??(e=>console.warn(e)),l=e.log??(e=>console.info(e)),u=[];return r&&u.push(p(t,n,c)),i&&u.push(_(t,s,l)),a&&u.push(g(t,c,o)),()=>{for(let e of u.splice(0))e()}},u=e=>typeof e==`function`&&typeof Reflect.get(e,`type`)!=`string`?`*`:t(e),d=()=>(((/* @__PURE__ */ Error()).stack?.split(`
1
+ import{t as e}from"./emit-key-C6FL7Ewn.js";import{t}from"./event-DVcW0mME.js";import{t as n}from"./site-DLvUoXs3.js";import{t as r}from"./mount-each-D54UqAhZ.js";const i=()=>{let e=Reflect.get(globalThis,`process`)?.getBuiltinModule,t=e?.(`node:async_hooks`)?.AsyncLocalStorage;return t?new t:null},a=e=>typeof e==`string`?e:e instanceof URL?e.href:String(Reflect.get(Object(e),`url`)),o=(e,t)=>t?.signal?t.signal:typeof Request==`function`&&e instanceof Request?e.signal:void 0,s=(e,t,n)=>{let r=i(),s=globalThis.fetch;if(!r||typeof s!=`function`)return null;let c=/* @__PURE__ */ new Set,l=/* @__PURE__ */ new WeakMap,u=e=>{for(let r of l.get(e)??[]){if(r.counted||r.settled||r.signal?.aborted)continue;r.counted=!0,t.push({kind:`request`,handler:e.type,event:r.url});let i=`${e.type}\u0000${r.url}`;c.has(i)||(c.add(i),n(`[nanoeffects] the handler of "${e.type}" has a request to "${r.url}" that kept running after its task was aborted. Pass ctx.signal to it (fetch(url, { signal: ctx.signal })), or make it through a client bound with fromSignal.`))}},d=(e,t,n,r)=>{let i={url:a(t),signal:o(t,n),settled:!1,counted:!1},s=()=>{i.settled=!0};r.then(s,s);let c=()=>u(e),d=l.get(e);d?d.push(i):(l.set(e,[i]),e.signal.addEventListener(`abort`,()=>queueMicrotask(c),{once:!0})),e.signal.aborted&&queueMicrotask(c)},f=(e,t)=>{let n=r.getStore(),i=s(e,t);return n&&d(n,e,t,i),i};globalThis.fetch=f;let p=e.emit,m=(...e)=>{let t=r.getStore();try{return p(...e)}finally{r.enterWith(t)}};return e.emit=m,{enter(e){r.enterWith(e);let t=e.run;e.run=(...e)=>{let n=r.getStore();try{return t(...e)}finally{r.enterWith(n)}}},stop(){globalThis.fetch===f&&(globalThis.fetch=s),e.emit===m&&(e.emit=p)}}},c=`[nanoeffects]`,l=(e={})=>t=>{let{maxListeners:n=10,leaks:r=!0,modules:i=!0,abandoned:a=!0,requests:o=!0,app:s}=e,c=e.log??(e=>console.warn(e)),l=e.log??(e=>console.info(e)),u=[];return r&&u.push(p(t,n,c)),i&&u.push(_(t,s,l)),a&&u.push(g(t,c,o)),()=>{for(let e of u.splice(0))e()}},u=e=>typeof e==`function`&&typeof Reflect.get(e,`type`)!=`string`?`*`:t(e),d=()=>(((/* @__PURE__ */ Error()).stack?.split(`
2
2
  `)??[])[4]??``).trim().replace(/^at /,``),f=e=>{let t=e[2],r=typeof t==`object`&&t?Reflect.get(t,`traceId`):void 0,i=typeof r==`string`?d():``;return typeof r==`string`&&i!==``?n(r,i):()=>{}},p=(e,t,n)=>{let r=e.on,i=/* @__PURE__ */ new Map,a=/* @__PURE__ */ new Set,o=!0,s=(...s)=>{if(!o)return Reflect.apply(r,e,s);let[l]=s,d=u(l),p=(i.get(d)??0)+1;i.set(d,p),p>t&&!a.has(d)&&(a.add(d),n(`${c} ${p} listeners of "${d}" were added and not unsubscribed (more than ${t}). A file that is reloaded and subscribes again, or an unsubscribe that is never called?`));let m=Reflect.apply(r,e,s),h=f(s),g=!1;return()=>{if(!g){g=!0,h();let e=(i.get(d)??1)-1;i.set(d,e),e<=t&&a.delete(d)}m()}};return e.on=s,()=>{o=!1,e.on===s&&(e.on=r)}},m=e=>typeof e==`string`?e:String(Reflect.get(Object(e),`type`)),h=/* @__PURE__ */ new WeakMap,g=(t,n,r)=>{let i=t.addContext,a=/* @__PURE__ */ new Set,o=[];h.set(t,o);let l=r?s(t,o,n):null,u=!0,d=t=>{i?.(t);let r=t;l?.enter(r);let s=r.emit;r.emit=(i,...l)=>{let d=Reflect.get(t,e),f=m(i),p=`${r.type}\u0000${f}`,h=u&&r.signal.aborted&&d!==!0;return h&&o.push({kind:`emit`,handler:r.type,event:f}),h&&!a.has(p)&&(a.add(p),n(`${c} the handler of "${r.type}" emitted "${f}" after its task was aborted, so the emit was dropped. The handler kept running after it was cancelled: pass ctx.signal to the request (fetch(url, { signal: ctx.signal })) or give ctx to an effect (retry(ctx, ...)).`)),s(i,...l)};let d=r.on;r.on=(e,...t)=>{let i=m(e),s=`${r.type}\u0000on\u0000${i}`,l=u&&r.signal.aborted;return l&&o.push({kind:`on`,handler:r.type,event:i}),l&&!a.has(s)&&(a.add(s),n(`${c} the handler of "${r.type}" subscribed to "${i}" after its task was aborted, so the subscription was dropped. The handler kept running after it was cancelled: pass ctx.signal to the request or give ctx to an effect.`)),d(e,...t)}};return t.addContext=d,()=>{u=!1,l?.stop(),t.addContext===d&&(t.addContext=i)}},_=(e,t,n)=>{let r=t??e.app;if(r)return v(r,n);let i,a;return Object.defineProperty(e,"app",{configurable:!0,enumerable:!0,get:()=>i,set:e=>{a?.(),i=e,a=e?v(e,n):void 0}}),()=>{a?.(),Object.defineProperty(e,"app",{configurable:!0,enumerable:!0,writable:!0,value:i})}},v=(e,t)=>{let{mount:n,unmount:i}=e,a=/* @__PURE__ */ new Map,o=r({mount:r=>{let i=a.get(r.name);if(i===r&&e.isMounted(r))return n(r);let o=i!==void 0&&e.isMounted(i),s=n(r);return a.set(r.name,r),t(`${c} module "${r.name}" ${o?`replaced`:`mounted`}`),()=>{e.isMounted(r)&&t(`${c} module "${r.name}" unmounted`),s()}},unmount:n=>{e.isMounted(n)&&t(`${c} module "${n.name}" unmounted`),i(n)},isMounted:t=>e.isMounted(t)});return e.mount=o.mount,e.unmount=o.unmount,()=>{e.mount=n,e.unmount=i}},y=async(e,t={})=>{let n=h.get(e);if(!n)throw Error("getLostSignalsCount: the bus has no devPlugin, or devPlugin has `abandoned: false`. Use bus.use(devPlugin()) before the test.");return await new Promise(e=>setTimeout(e,t.wait??0)),[...n]},b=async(e,t={})=>(await y(e,t)).length;export{l as devPlugin,y as getLostSignals,b as getLostSignalsCount};
@@ -47,6 +47,8 @@ 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)).
51
+
50
52
  Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
51
53
 
52
54
  ## Wait for a pause in typing
@@ -16,6 +16,7 @@ A loop is two handlers that emit each other's events, or one handler that emits
16
16
  - [Cut a loop at run time](#cut-a-loop-at-run-time)
17
17
  - [Fail a test on a loop](#fail-a-test-on-a-loop)
18
18
  - [Export the graph and the payloads](#export-the-graph-and-the-payloads)
19
+ - [Know how a loop is found](#know-how-a-loop-is-found)
19
20
  - [Know the limits](#know-the-limits)
20
21
 
21
22
  ## Start from the symptom
@@ -277,6 +278,32 @@ console.log(JSON.stringify(data));
277
278
 
278
279
  A payload can be a class instance, a `Map`, a circular object or an `Error`, which a plain `JSON.stringify` cannot take. The exports make them plain JSON: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ "$map": [...] }`, and an instance of a class keeps its own fields and its name as `$class`. Getters are never called, and a value that is too deep or too big is cut.
279
280
 
281
+ ## Know how a loop is found
282
+
283
+ `graphLogger` finds loops in two ways. They give two kinds of finding in `graph.cycles()`, told apart by `source`:
284
+
285
+ | | `source: "chain"` | `source: "graph"` |
286
+ | - | ----------------- | ----------------- |
287
+ | What it reads | One real run: the chain of tasks that led to the current one | Every edge seen so far, from all runs |
288
+ | The rule | The same node is in the chain `repeats` times (3 by default) | A strongly connected component of the graph (Tarjan): several nodes that reach each other, or a node that feeds itself |
289
+ | What you get | A warning, `onCycle`, DevTools, and the path | A line in `cycles()` and red edges in the exports; no warning |
290
+ | It finds | A loop that really went round | A loop that could: `a` leads to `b` in one run and `b` to `a` in another |
291
+ | It is wrong about | A recursion that ends looks like a loop after three turns | Every recursion looks like a cycle, since an event that feeds itself is a cycle of one node |
292
+
293
+ So the graph is searched, and the search is exact (no heuristic): `source: "graph"` is a real cycle of the edges. What no tool can decide is whether a cycle ever ends. A tree walk that emits `tree/visit` for each child is a cycle in the graph and in the chain, and it ends. The only evidence that separates a loop from such a recursion is how often it repeats, which is why the chain finding has a threshold (`repeats`) and why you tell the logger about recursion on purpose with `ignore` ([below](#code-that-repeats-on-purpose-looks-like-a-loop)). For a loop that must be stopped, not found, use [`loopGuardPlugin`](loop-guard.md): it cuts a chain at a limit and does not need to decide.
294
+
295
+ ```js
296
+ // source.js
297
+ import { graphLogger } from "nanoeffects/trace";
298
+
299
+ const graph = graphLogger();
300
+
301
+ for (const cycle of graph.cycles()) {
302
+ // "chain": it ran as one chain (and was reported); "graph": the edges of different runs close it
303
+ console.log(cycle.source, cycle.path.join(" → "));
304
+ }
305
+ ```
306
+
280
307
  ## Know the limits
281
308
 
282
309
  ### Code that repeats on purpose looks like a loop
@@ -12,6 +12,7 @@ Use it as a safety net, also in production. It needs no tracer and changes nothi
12
12
  - [Choose what happens at the limit](#choose-what-happens-at-the-limit)
13
13
  - [Limit the emits that come from outside](#limit-the-emits-that-come-from-outside)
14
14
  - [Leave a recursion out](#leave-a-recursion-out)
15
+ - [Know what it costs](#know-what-it-costs)
15
16
  - [Know what it does not do](#know-what-it-does-not-do)
16
17
 
17
18
  ## Cut a loop
@@ -102,6 +103,18 @@ export const bus = createBus().use(
102
103
  );
103
104
  ```
104
105
 
106
+ ## Know what it costs
107
+
108
+ The plugin gives every task a small record and wraps its `emit` and `run`, so the cost is the same for each emit and does not grow with the length of the chain. On a laptop, measured with `node --expose-gc scripts/bench-guard.mjs`:
109
+
110
+ | Case | Without the plugin | With the plugin | Extra for each task |
111
+ | ---- | ------------------ | --------------- | ------------------- |
112
+ | A handler, no chain (100 000 emits) | 15 ms | 38 ms | about 230 ns |
113
+ | A chain of 5 events | 18 ms (20 000 chains) | 44 ms | about the same |
114
+ | A chain of 200 events, limits not reached | 36 ms (1 000 chains) | 163 ms | about 0.6 µs |
115
+
116
+ A chain of 200 events costs the same whether the check walks the chain (`maxRepeats` 50) or not (`maxRepeats` 500), so the walk over the chain is not what you pay for. Around 230 ns to 0.6 µs for each task is small beside a handler that calls the network, and it is a few percent of a core at 100 000 tasks a second. The numbers depend on the machine; run the script on yours if the emit is on a hot path.
117
+
105
118
  ## Know what it does not do
106
119
 
107
120
  - **It does not abort the chain.** It refuses one emit, at the edge where the limit is passed. The handlers that already run, run to the end.
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` | an outside `AbortSignal` (request, React Query) must stop tasks |
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 |
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 |
@@ -29,7 +29,7 @@ Each row is optional. The "when" column says what need brings you to it.
29
29
  | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
30
30
  | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
31
31
  | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
32
- | `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
32
+ | `nanoeffects/dev` | `devPlugin` (no-op in production), `getLostSignalsCount` and `getLostSignals` for tests | you look for leaks and lost signals in development and in tests |
33
33
  | `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops) and `tracePlugin` (names for the trace) | you want to see what ran and why |
34
34
  | `nanoeffects/guard` | `loopGuardPlugin`: refuses the emit that would pass a limit of a chain | a loop must not take the app down |
35
35
 
@@ -116,6 +116,7 @@ Each row is a situation where the signal is easy to lose. The right column says
116
116
  | A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
117
117
  | A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
118
118
  | A chain: `api.news(q).then(...)` | Pass the signal to the first call | yes | |
119
+ | Work that is only started: `setTimeout(() => api.refresh(), 1000)`, `api.x().then(cb)` with no `return`, `stream.on("data", cb)` | The callback runs after the handler returned. Check `ctx.signal.aborted` in it, or pass the signal to what starts it (`addEventListener("abort", cb, { signal })`) | yes | |
119
120
  | Another signal: `AbortSignal.timeout(5000)`, or the signal of your own `AbortController` | Join it with the handler's: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])` | yes | |
120
121
  | Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
121
122
  | A library that takes no signal (a cache, a local database) | List it in `ignore`, or check only the calls on your list with `only`. With type information the rule skips a function that takes no signal on its own | `ignore`, `only`, types | |
@@ -223,7 +224,7 @@ The rule cannot tell, from the call alone, whether the function takes a signal.
223
224
  - **`requireContext`** needs no list: set it to `true` when handlers make requests through `ctx` (see [Bind the signal once](#bind-the-signal-once)), and any call that does not go through `ctx` is reported. `ignore` still skips a call.
224
225
  - **`ignore`** lists the calls to skip, by name: `ignore: ["cache.get", "storage.*"]`. Use it when most calls take a signal and a few cannot.
225
226
  - **`only`** lists the calls to check, by name: `only: ["api.*", "fetch"]`. Anything else is left alone, so a call that is not on the list is never reported. Use it when you know which clients take a signal. A call on both lists is skipped, and a pause that nothing can cancel (`await new Promise(...)`) is reported whatever `only` says.
226
- - **Type information** needs no list. With `@typescript-eslint/parser` and a `project`, the rule reads the function it calls. A call that does not get the signal is reported only when the function takes one (a parameter typed `AbortSignal`, or an options object with `signal`), so `cache.get(key)` is left alone. A call that passes the signal is reported when the function is in your sources and never uses it: the parameter is unused, or only other options are read from it. A signal forwarded to another function of yours is followed up to three calls deep, and a function from a library is trusted. A callee typed `any` is reported as before.
227
+ - **Type information** needs no list. With `@typescript-eslint/parser` and a `project`, the rule reads the function it calls. A call that does not get the signal is reported only when the function takes one (a parameter typed `AbortSignal`, or an options object with `signal`), so `cache.get(key)` is left alone. A call that passes the signal is reported when the function is in your sources and never uses it: the parameter is unused, or only other options are read from it. A signal forwarded to another function of yours is followed up to three calls deep, and a function from a library is trusted. The rule reads overloads (a call is reported if any overload takes a signal), the implementation behind an overload, class methods, generic functions, interface methods, function type aliases, rest lists and tuples, `AbortSignal | null`, and `RequestInit` from the library. A callee typed `any` is reported as before.
227
228
 
228
229
  <!-- docs-check: skip - a config file of the reader's project -->
229
230
  ```js
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.
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)).
28
28
 
29
29
  ## Choose a policy
30
30
 
@@ -47,6 +47,8 @@ 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#подсчёт-потерянных-сигналов-в-тесте)).
51
+
50
52
  Результат: после `emit("search", "ab")` и `emit("search", "abc")` вы увидите только результаты для `abc`, а запрос для `ab` отменится.
51
53
 
52
54
  ## Пауза в наборе текста
@@ -16,6 +16,7 @@
16
16
  - [Обрыв цикла во время работы](#обрыв-цикла-во-время-работы)
17
17
  - [Падение теста при цикле](#падение-теста-при-цикле)
18
18
  - [Экспорт графа и payload](#экспорт-графа-и-payload)
19
+ - [Как находится цикл](#как-находится-цикл)
19
20
  - [Ограничения](#ограничения)
20
21
 
21
22
  ## Симптомы
@@ -277,6 +278,32 @@ console.log(JSON.stringify(data));
277
278
 
278
279
  Payload может быть экземпляром класса, `Map`, объектом с циклической ссылкой или `Error`, которые обычный `JSON.stringify` не примет. Экспорт превращает их в обычный JSON: у `Error` остаются имя, сообщение, стек и причина, цикл становится `"[Circular]"`, `Map` — `{ "$map": [...] }`, а экземпляр класса сохраняет собственные поля и имя класса в `$class`. Геттеры не вызываются, а слишком глубокое или слишком большое значение обрезается.
279
280
 
281
+ ## Как находится цикл
282
+
283
+ `graphLogger` находит циклы двумя способами. Они дают два вида находок в `graph.cycles()`, которые различает поле `source`:
284
+
285
+ | | `source: "chain"` | `source: "graph"` |
286
+ | - | ----------------- | ----------------- |
287
+ | Что читает | Один настоящий запуск: цепочку задач, которая привела к текущей | Все рёбра, замеченные до сих пор, из всех запусков |
288
+ | Правило | Один и тот же узел стоит в цепочке `repeats` раз (по умолчанию 3) | Сильно связная компонента графа (алгоритм Тарьяна): несколько узлов, которые достижимы друг из друга, или узел, который питает сам себя |
289
+ | Что вы получаете | Предупреждение, `onCycle`, DevTools и путь | Строку в `cycles()` и красные рёбра в экспорте; предупреждения нет |
290
+ | Что находит | Цикл, который действительно прошёл по кругу | Цикл, который мог бы быть: `a` ведёт к `b` в одном запуске, а `b` к `a` в другом |
291
+ | Где ошибается | Рекурсия, которая заканчивается, выглядит циклом после трёх оборотов | Любая рекурсия выглядит циклом: событие, которое питает само себя, — цикл из одного узла |
292
+
293
+ Так что по графу поиск идёт, и поиск точный (не эвристика): `source: "graph"` — настоящий цикл рёбер. Ни один инструмент не может решить другое: закончится ли цикл когда-нибудь. Обход дерева, который отправляет `tree/visit` для каждого потомка, — цикл и в графе, и в цепочке, и он заканчивается. Единственный признак, который отличает цикл от такой рекурсии, — как часто он повторяется. Поэтому у находки по цепочке есть порог (`repeats`), а намеренную рекурсию вы объясняете логгеру через `ignore` ([ниже](#намеренный-повтор-выглядит-как-цикл)). Если цикл надо не находить, а останавливать, используйте [`loopGuardPlugin`](loop-guard.md): он обрывает цепочку на пределе и ничего не решает.
294
+
295
+ ```js
296
+ // source.js
297
+ import { graphLogger } from "nanoeffects/trace";
298
+
299
+ const graph = graphLogger();
300
+
301
+ for (const cycle of graph.cycles()) {
302
+ // "chain": прошёл как одна цепочка (и о нём сообщили); "graph": его замыкают рёбра разных запусков
303
+ console.log(cycle.source, cycle.path.join(" → "));
304
+ }
305
+ ```
306
+
280
307
  ## Ограничения
281
308
 
282
309
  ### Намеренный повтор выглядит как цикл
@@ -12,6 +12,7 @@
12
12
  - [Выбор действия на пределе](#выбор-действия-на-пределе)
13
13
  - [Ограничение emit извне](#ограничение-emit-извне)
14
14
  - [Исключение рекурсии](#исключение-рекурсии)
15
+ - [Во сколько он обходится](#во-сколько-он-обходится)
15
16
  - [Чего плагин не делает](#чего-плагин-не-делает)
16
17
 
17
18
  ## Обрыв цикла
@@ -102,6 +103,18 @@ export const bus = createBus().use(
102
103
  );
103
104
  ```
104
105
 
106
+ ## Во сколько он обходится
107
+
108
+ Плагин даёт каждой задаче небольшую запись и оборачивает её `emit` и `run`, поэтому цена одинакова для каждого emit и не растёт с длиной цепочки. Измерения на ноутбуке через `node --expose-gc scripts/bench-guard.mjs`:
109
+
110
+ | Случай | Без плагина | С плагином | Добавка на одну задачу |
111
+ | ------ | ----------- | ---------- | ---------------------- |
112
+ | Один обработчик без цепочки (100 000 emit) | 15 мс | 38 мс | около 230 нс |
113
+ | Цепочка из 5 событий | 18 мс (20 000 цепочек) | 44 мс | примерно столько же |
114
+ | Цепочка из 200 событий, пределы не достигнуты | 36 мс (1 000 цепочек) | 163 мс | около 0,6 мкс |
115
+
116
+ Цепочка из 200 событий стоит одинаково, обходит ли проверка цепочку (`maxRepeats` 50) или нет (`maxRepeats` 500), так что платить приходится не за обход цепочки. От 230 нс до 0,6 мкс на задачу — немного рядом с обработчиком, который ходит в сеть, и это несколько процентов одного ядра при 100 000 задач в секунду. Цифры зависят от машины: если emit стоит на горячем пути, запустите скрипт на своей.
117
+
105
118
  ## Чего плагин не делает
106
119
 
107
120
  - **Он не прерывает цепочку.** Он отклоняет один emit, на ребре, где превышен предел. Обработчики, которые уже работают, дорабатывают до конца.
@@ -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` | внешний `AbortSignal` (запрос, React Query) должен останавливать задачи |
20
+ | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` привязывает задачи этого `emit` к внешнему `AbortSignal`; `fromSignal` и `signalFetch`: клиент или `fetch`, созданный из сигнала каждой задачи | внешний `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 |
@@ -25,7 +25,7 @@
25
25
  | `nanoeffects/react` | `reactPlugin`: добавляет хук `useEmitter` на шину (нужен React 18 или новее) | компоненты React |
26
26
  | `nanoeffects/svelte` | `sveltePlugin`: добавляет `eventStore` на шину (нужен Svelte 5 или новее) | компоненты Svelte |
27
27
  | `nanoeffects/vue` | `vuePlugin`: добавляет `watchEmitter` и `useEventRef` на шину (нужен Vue 3.3 или новее) | компоненты Vue |
28
- | `nanoeffects/dev` | `devPlugin` (в production ничего не делает) | нужно находить утечки подписок при разработке |
28
+ | `nanoeffects/dev` | `devPlugin` (в production ничего не делает), `getLostSignalsCount` и `getLostSignals` для тестов | нужно находить утечки подписок и потерянные сигналы при разработке и в тестах |
29
29
  | `nanoeffects/trace` | `createTracer`, логгеры (`graphLogger` находит циклы событий) и `tracePlugin` (имена для трассировки) | нужно видеть, что выполнилось и почему |
30
30
  | `nanoeffects/guard` | `loopGuardPlugin`: отклоняет emit, который превысил бы предел цепочки | цикл не должен ронять приложение |
31
31
 
@@ -116,6 +116,7 @@ bus.on(
116
116
  | Пауза: `await new Promise((r) => setTimeout(r, 1000))` | Используйте `delay(ctx, 1000)` | да | |
117
117
  | Поток: `for await (const x of api.stream(q))` | Передайте сигнал в `api.stream` | да | |
118
118
  | Цепочка: `api.news(q).then(...)` | Передайте сигнал первому вызову | да | |
119
+ | Работа, которая только запущена: `setTimeout(() => api.refresh(), 1000)`, `api.x().then(cb)` без `return`, `stream.on("data", cb)` | Callback выполнится после возврата обработчика. Проверьте в нём `ctx.signal.aborted` или передайте сигнал тому, что его запускает (`addEventListener("abort", cb, { signal })`) | да | |
119
120
  | Чужой сигнал: `AbortSignal.timeout(5000)` или сигнал вашего `AbortController` | Объедините его с сигналом обработчика: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])` | да | |
120
121
  | Чтение тела ответа: `await response.json()` | Ничего: сигнал запроса останавливает и его | принимает | |
121
122
  | Библиотека, которая не принимает сигнал (кэш, локальная база) | Укажите её в `ignore` или проверяйте только вызовы из своего списка через `only`. С информацией о типах правило само пропускает функцию без сигнала | `ignore`, `only`, типы | |
@@ -223,7 +224,7 @@ bus.use(devPlugin());
223
224
  - **`requireContext`** списка не требует: включите `true`, если обработчики делают запросы через `ctx` (см. [Привязка сигнала один раз](#привязка-сигнала-один-раз)), и любой вызов не через `ctx` попадёт в отчёт. `ignore` по-прежнему пропускает вызов.
224
225
  - **`ignore`** перечисляет вызовы, которые нужно пропустить, по имени: `ignore: ["cache.get", "storage.*"]`. Подходит, когда большинство вызовов принимает сигнал, а несколько не могут.
225
226
  - **`only`** перечисляет вызовы, которые нужно проверять, по имени: `only: ["api.*", "fetch"]`. Всё остальное правило не трогает, так что вызов не из списка никогда не попадёт в отчёт. Подходит, когда вы знаете, какие клиенты принимают сигнал. Вызов из обоих списков пропускается, а пауза, которую нельзя отменить (`await new Promise(...)`), попадает в отчёт независимо от `only`.
226
- - **Информация о типах** списков не требует. С `@typescript-eslint/parser` и `project` правило читает вызываемую функцию. Вызов без сигнала попадает в отчёт, только если функция его принимает (параметр типа `AbortSignal` или объект параметров с `signal`), поэтому `cache.get(key)` правило не трогает. Вызов с сигналом попадает в отчёт, если функция лежит в ваших исходниках и не использует сигнал: параметр не используется или из него читаются только другие параметры. Сигнал, переданный в другую вашу функцию, правило прослеживает до трёх вызовов вглубь, а функцию из библиотеки считает надёжной. Вызываемое с типом `any` попадает в отчёт, как раньше.
227
+ - **Информация о типах** списков не требует. С `@typescript-eslint/parser` и `project` правило читает вызываемую функцию. Вызов без сигнала попадает в отчёт, только если функция его принимает (параметр типа `AbortSignal` или объект параметров с `signal`), поэтому `cache.get(key)` правило не трогает. Вызов с сигналом попадает в отчёт, если функция лежит в ваших исходниках и не использует сигнал: параметр не используется или из него читаются только другие параметры. Сигнал, переданный в другую вашу функцию, правило прослеживает до трёх вызовов вглубь, а функцию из библиотеки считает надёжной. Правило читает перегрузки (вызов попадает в отчёт, если сигнал принимает хотя бы одна перегрузка), реализацию за перегрузкой, методы классов, обобщённые функции, методы интерфейсов, псевдонимы типов функций, rest-списки и кортежи, `AbortSignal | null` и `RequestInit` из библиотеки. Вызываемое с типом `any` попадает в отчёт, как раньше.
227
228
 
228
229
  <!-- docs-check: skip - файл настройки проекта читателя -->
229
230
  ```js
@@ -24,7 +24,7 @@ bus.on(
24
24
  );
25
25
  ```
26
26
 
27
- Передайте `signal` в `fetch` (и подобные вызовы), чтобы запрос отменялся.
27
+ Передайте `signal` в `fetch` (и подобные вызовы), чтобы запрос отменялся. Чтобы не передавать его вообще, привяжите сигнал один раз: с `ctx.fetch` запрос останавливается вместе с обработчиком, и забыть нечего ([Сохранение сигнала в обработчике](pass-the-signal.md#привязка-сигнала-один-раз)).
28
28
 
29
29
  ## Таблица политик
30
30
 
package/llms-full.txt CHANGED
@@ -1277,7 +1277,7 @@ Each row is optional. The "when" column says what need brings you to it.
1277
1277
  | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
1278
1278
  | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
1279
1279
  | `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 |
1280
- | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal` | an outside `AbortSignal` (request, React Query) must stop tasks |
1280
+ | `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 |
1281
1281
  | `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 |
1282
1282
  | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
1283
1283
  | `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
@@ -1285,7 +1285,7 @@ Each row is optional. The "when" column says what need brings you to it.
1285
1285
  | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) | React components |
1286
1286
  | `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
1287
1287
  | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
1288
- | `nanoeffects/dev` | `devPlugin` (no-op in production) | you look for leaks in development |
1288
+ | `nanoeffects/dev` | `devPlugin` (no-op in production), `getLostSignalsCount` and `getLostSignals` for tests | you look for leaks and lost signals in development and in tests |
1289
1289
  | `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops) and `tracePlugin` (names for the trace) | you want to see what ran and why |
1290
1290
  | `nanoeffects/guard` | `loopGuardPlugin`: refuses the emit that would pass a limit of a chain | a loop must not take the app down |
1291
1291
 
@@ -1736,7 +1736,7 @@ bus.on(
1736
1736
  );
1737
1737
  ```
1738
1738
 
1739
- Pass `signal` to `fetch` (and the like) so the request cancels.
1739
+ 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)).
1740
1740
 
1741
1741
  ## Choose a policy
1742
1742
 
@@ -1966,6 +1966,8 @@ bus.on("results", (items) => render(items));
1966
1966
 
1967
1967
  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.
1968
1968
 
1969
+ 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)).
1970
+
1969
1971
  Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
1970
1972
 
1971
1973
  ## Wait for a pause in typing
@@ -7463,6 +7465,7 @@ Each row is a situation where the signal is easy to lose. The right column says
7463
7465
  | A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
7464
7466
  | A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
7465
7467
  | A chain: `api.news(q).then(...)` | Pass the signal to the first call | yes | |
7468
+ | Work that is only started: `setTimeout(() => api.refresh(), 1000)`, `api.x().then(cb)` with no `return`, `stream.on("data", cb)` | The callback runs after the handler returned. Check `ctx.signal.aborted` in it, or pass the signal to what starts it (`addEventListener("abort", cb, { signal })`) | yes | |
7466
7469
  | Another signal: `AbortSignal.timeout(5000)`, or the signal of your own `AbortController` | Join it with the handler's: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])` | yes | |
7467
7470
  | Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
7468
7471
  | A library that takes no signal (a cache, a local database) | List it in `ignore`, or check only the calls on your list with `only`. With type information the rule skips a function that takes no signal on its own | `ignore`, `only`, types | |
@@ -7570,7 +7573,7 @@ The rule cannot tell, from the call alone, whether the function takes a signal.
7570
7573
  - **`requireContext`** needs no list: set it to `true` when handlers make requests through `ctx` (see [Bind the signal once](#bind-the-signal-once)), and any call that does not go through `ctx` is reported. `ignore` still skips a call.
7571
7574
  - **`ignore`** lists the calls to skip, by name: `ignore: ["cache.get", "storage.*"]`. Use it when most calls take a signal and a few cannot.
7572
7575
  - **`only`** lists the calls to check, by name: `only: ["api.*", "fetch"]`. Anything else is left alone, so a call that is not on the list is never reported. Use it when you know which clients take a signal. A call on both lists is skipped, and a pause that nothing can cancel (`await new Promise(...)`) is reported whatever `only` says.
7573
- - **Type information** needs no list. With `@typescript-eslint/parser` and a `project`, the rule reads the function it calls. A call that does not get the signal is reported only when the function takes one (a parameter typed `AbortSignal`, or an options object with `signal`), so `cache.get(key)` is left alone. A call that passes the signal is reported when the function is in your sources and never uses it: the parameter is unused, or only other options are read from it. A signal forwarded to another function of yours is followed up to three calls deep, and a function from a library is trusted. A callee typed `any` is reported as before.
7576
+ - **Type information** needs no list. With `@typescript-eslint/parser` and a `project`, the rule reads the function it calls. A call that does not get the signal is reported only when the function takes one (a parameter typed `AbortSignal`, or an options object with `signal`), so `cache.get(key)` is left alone. A call that passes the signal is reported when the function is in your sources and never uses it: the parameter is unused, or only other options are read from it. A signal forwarded to another function of yours is followed up to three calls deep, and a function from a library is trusted. The rule reads overloads (a call is reported if any overload takes a signal), the implementation behind an overload, class methods, generic functions, interface methods, function type aliases, rest lists and tuples, `AbortSignal | null`, and `RequestInit` from the library. A callee typed `any` is reported as before.
7574
7577
 
7575
7578
  <!-- docs-check: skip - a config file of the reader's project -->
7576
7579
  ```js
@@ -8183,6 +8186,7 @@ A loop is two handlers that emit each other's events, or one handler that emits
8183
8186
  - [Cut a loop at run time](#cut-a-loop-at-run-time)
8184
8187
  - [Fail a test on a loop](#fail-a-test-on-a-loop)
8185
8188
  - [Export the graph and the payloads](#export-the-graph-and-the-payloads)
8189
+ - [Know how a loop is found](#know-how-a-loop-is-found)
8186
8190
  - [Know the limits](#know-the-limits)
8187
8191
 
8188
8192
  ## Start from the symptom
@@ -8444,6 +8448,32 @@ console.log(JSON.stringify(data));
8444
8448
 
8445
8449
  A payload can be a class instance, a `Map`, a circular object or an `Error`, which a plain `JSON.stringify` cannot take. The exports make them plain JSON: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ "$map": [...] }`, and an instance of a class keeps its own fields and its name as `$class`. Getters are never called, and a value that is too deep or too big is cut.
8446
8450
 
8451
+ ## Know how a loop is found
8452
+
8453
+ `graphLogger` finds loops in two ways. They give two kinds of finding in `graph.cycles()`, told apart by `source`:
8454
+
8455
+ | | `source: "chain"` | `source: "graph"` |
8456
+ | - | ----------------- | ----------------- |
8457
+ | What it reads | One real run: the chain of tasks that led to the current one | Every edge seen so far, from all runs |
8458
+ | The rule | The same node is in the chain `repeats` times (3 by default) | A strongly connected component of the graph (Tarjan): several nodes that reach each other, or a node that feeds itself |
8459
+ | What you get | A warning, `onCycle`, DevTools, and the path | A line in `cycles()` and red edges in the exports; no warning |
8460
+ | It finds | A loop that really went round | A loop that could: `a` leads to `b` in one run and `b` to `a` in another |
8461
+ | It is wrong about | A recursion that ends looks like a loop after three turns | Every recursion looks like a cycle, since an event that feeds itself is a cycle of one node |
8462
+
8463
+ So the graph is searched, and the search is exact (no heuristic): `source: "graph"` is a real cycle of the edges. What no tool can decide is whether a cycle ever ends. A tree walk that emits `tree/visit` for each child is a cycle in the graph and in the chain, and it ends. The only evidence that separates a loop from such a recursion is how often it repeats, which is why the chain finding has a threshold (`repeats`) and why you tell the logger about recursion on purpose with `ignore` ([below](#code-that-repeats-on-purpose-looks-like-a-loop)). For a loop that must be stopped, not found, use [`loopGuardPlugin`](loop-guard.md): it cuts a chain at a limit and does not need to decide.
8464
+
8465
+ ```js
8466
+ // source.js
8467
+ import { graphLogger } from "nanoeffects/trace";
8468
+
8469
+ const graph = graphLogger();
8470
+
8471
+ for (const cycle of graph.cycles()) {
8472
+ // "chain": it ran as one chain (and was reported); "graph": the edges of different runs close it
8473
+ console.log(cycle.source, cycle.path.join(" → "));
8474
+ }
8475
+ ```
8476
+
8447
8477
  ## Know the limits
8448
8478
 
8449
8479
  ### Code that repeats on purpose looks like a loop
@@ -8501,6 +8531,7 @@ Use it as a safety net, also in production. It needs no tracer and changes nothi
8501
8531
  - [Choose what happens at the limit](#choose-what-happens-at-the-limit)
8502
8532
  - [Limit the emits that come from outside](#limit-the-emits-that-come-from-outside)
8503
8533
  - [Leave a recursion out](#leave-a-recursion-out)
8534
+ - [Know what it costs](#know-what-it-costs)
8504
8535
  - [Know what it does not do](#know-what-it-does-not-do)
8505
8536
 
8506
8537
  ## Cut a loop
@@ -8591,6 +8622,18 @@ export const bus = createBus().use(
8591
8622
  );
8592
8623
  ```
8593
8624
 
8625
+ ## Know what it costs
8626
+
8627
+ The plugin gives every task a small record and wraps its `emit` and `run`, so the cost is the same for each emit and does not grow with the length of the chain. On a laptop, measured with `node --expose-gc scripts/bench-guard.mjs`:
8628
+
8629
+ | Case | Without the plugin | With the plugin | Extra for each task |
8630
+ | ---- | ------------------ | --------------- | ------------------- |
8631
+ | A handler, no chain (100 000 emits) | 15 ms | 38 ms | about 230 ns |
8632
+ | A chain of 5 events | 18 ms (20 000 chains) | 44 ms | about the same |
8633
+ | A chain of 200 events, limits not reached | 36 ms (1 000 chains) | 163 ms | about 0.6 µs |
8634
+
8635
+ A chain of 200 events costs the same whether the check walks the chain (`maxRepeats` 50) or not (`maxRepeats` 500), so the walk over the chain is not what you pay for. Around 230 ns to 0.6 µs for each task is small beside a handler that calls the network, and it is a few percent of a core at 100 000 tasks a second. The numbers depend on the machine; run the script on yours if the emit is on a hot path.
8636
+
8594
8637
  ## Know what it does not do
8595
8638
 
8596
8639
  - **It does not abort the chain.** It refuses one emit, at the edge where the limit is passed. The handlers that already run, run to the end.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nanoeffects",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
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",