nanoeffects 0.30.1 → 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.d.ts CHANGED
@@ -12,6 +12,8 @@ type DevOptions<E extends EventMap = EventMap> = {
12
12
  modules?: boolean;
13
13
  /** Warn when a handler emits after its task was aborted, which means it ignored `ctx.signal`. Default `true`. */
14
14
  abandoned?: boolean;
15
+ /** Follow the `fetch` requests of the handlers, and count the ones that kept running after a cancel. Node only; needs `abandoned`. Default `true`. */
16
+ requests?: boolean;
15
17
  /** Receives every message. Default: `console.warn` for leaks and `console.info` for modules. */
16
18
  log?: (message: string) => void;
17
19
  /** What `modulePlugin` returned, to report its modules. Without it the app that `modulePlugin` put on the bus is used, in either order of the two plugins. */
@@ -19,11 +21,14 @@ type DevOptions<E extends EventMap = EventMap> = {
19
21
  };
20
22
  /** One signal that a handler lost: it kept working after its task was aborted. */
21
23
  type LostSignal = {
22
- /** `emit`: the handler emitted after the abort (the emit was dropped); `on`: it subscribed after the abort. */
23
- kind: "emit" | "on";
24
+ /**
25
+ * `emit`: the handler emitted after the abort (the emit was dropped); `on`: it subscribed after the abort;
26
+ * `request`: a `fetch` of the handler was still running after the abort and did not stop (Node only).
27
+ */
28
+ kind: "emit" | "on" | "request";
24
29
  /** The type of the task that was aborted: the event its handler works on. */
25
30
  handler: string;
26
- /** The event that was emitted or subscribed to. */
31
+ /** The event that was emitted or subscribed to, or the url of the request. */
27
32
  event: string;
28
33
  };
29
34
  /** What `getLostSignalsCount` takes. */
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=`[nanoeffects]`,a=(e={})=>t=>{let{maxListeners:n=10,leaks:r=!0,modules:i=!0,abandoned:a=!0,app:o}=e,s=e.log??(e=>console.warn(e)),c=e.log??(e=>console.info(e)),u=[];return r&&u.push(l(t,n,s)),i&&u.push(p(t,o,c)),a&&u.push(f(t,s)),()=>{for(let e of u.splice(0))e()}},o=e=>typeof e==`function`&&typeof Reflect.get(e,`type`)!=`string`?`*`:t(e),s=()=>(((/* @__PURE__ */ Error()).stack?.split(`
2
- `)??[])[4]??``).trim().replace(/^at /,``),c=e=>{let t=e[2],r=typeof t==`object`&&t?Reflect.get(t,`traceId`):void 0,i=typeof r==`string`?s():``;return typeof r==`string`&&i!==``?n(r,i):()=>{}},l=(e,t,n)=>{let r=e.on,a=/* @__PURE__ */ new Map,s=/* @__PURE__ */ new Set,l=!0,u=(...u)=>{if(!l)return Reflect.apply(r,e,u);let[d]=u,f=o(d),p=(a.get(f)??0)+1;a.set(f,p),p>t&&!s.has(f)&&(s.add(f),n(`${i} ${p} listeners of "${f}" 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,u),h=c(u),g=!1;return()=>{if(!g){g=!0,h();let e=(a.get(f)??1)-1;a.set(f,e),e<=t&&s.delete(f)}m()}};return e.on=u,()=>{l=!1,e.on===u&&(e.on=r)}},u=e=>typeof e==`string`?e:String(Reflect.get(Object(e),`type`)),d=/* @__PURE__ */ new WeakMap,f=(t,n)=>{let r=t.addContext,a=/* @__PURE__ */ new Set,o=[];d.set(t,o);let s=!0,c=t=>{r?.(t);let c=t,l=c.emit;c.emit=(r,...d)=>{let f=Reflect.get(t,e),p=u(r),m=`${c.type}\u0000${p}`,h=s&&c.signal.aborted&&f!==!0;return h&&o.push({kind:`emit`,handler:c.type,event:p}),h&&!a.has(m)&&(a.add(m),n(`${i} the handler of "${c.type}" emitted "${p}" 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, ...)).`)),l(r,...d)};let d=c.on;c.on=(e,...t)=>{let r=u(e),l=`${c.type}\u0000on\u0000${r}`,f=s&&c.signal.aborted;return f&&o.push({kind:`on`,handler:c.type,event:r}),f&&!a.has(l)&&(a.add(l),n(`${i} the handler of "${c.type}" subscribed to "${r}" 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=c,()=>{s=!1,t.addContext===c&&(t.addContext=r)}},p=(e,t,n)=>{let r=t??e.app;if(r)return m(r,n);let i,a;return Object.defineProperty(e,"app",{configurable:!0,enumerable:!0,get:()=>i,set:e=>{a?.(),i=e,a=e?m(e,n):void 0}}),()=>{a?.(),Object.defineProperty(e,"app",{configurable:!0,enumerable:!0,writable:!0,value:i})}},m=(e,t)=>{let{mount:n,unmount:a}=e,o=/* @__PURE__ */ new Map,s=r({mount:r=>{let a=o.get(r.name);if(a===r&&e.isMounted(r))return n(r);let s=a!==void 0&&e.isMounted(a),c=n(r);return o.set(r.name,r),t(`${i} module "${r.name}" ${s?`replaced`:`mounted`}`),()=>{e.isMounted(r)&&t(`${i} module "${r.name}" unmounted`),c()}},unmount:n=>{e.isMounted(n)&&t(`${i} module "${n.name}" unmounted`),a(n)},isMounted:t=>e.isMounted(t)});return e.mount=s.mount,e.unmount=s.unmount,()=>{e.mount=n,e.unmount=a}},h=async(e,t={})=>{let n=d.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]},g=async(e,t={})=>(await h(e,t)).length;export{a as devPlugin,h as getLostSignals,g as getLostSignalsCount};
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
+ `)??[])[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};
package/dist/signal.d.ts CHANGED
@@ -1,5 +1,25 @@
1
1
  import { A as HandledCall, C as Emit, E as EventLike, H as EventMap, s as EmitScope, u as WithScope, v as Bus } from "./action-creator-6rlBTmH9.js";
2
2
  import { n as FromSignal } from "./context-types-DKNc5mkS.js";
3
+ //#region src/signal/fetch.d.ts
4
+ /**
5
+ * A `fetch` that carries a given signal, so the request stops when the signal aborts and the caller never passes
6
+ * it. Give it to `fromSignal` and every task gets a `ctx.fetch` made from its own signal. A call that has a
7
+ * `signal` of its own stops with whichever aborts first.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * const bus = createBus().use(
12
+ * contextPlugin({ fetch: fromSignal(signalFetch) }),
13
+ * );
14
+ *
15
+ * bus.on("search", latest(async (query, ctx) => {
16
+ * const response = await ctx.fetch(`/api?q=${query}`); // stops when the policy cancels the handler
17
+ * ctx.emit("results", await response.json());
18
+ * }));
19
+ * ```
20
+ */
21
+ export declare const signalFetch: (signal: AbortSignal) => typeof fetch;
22
+ //#endregion
3
23
  //#region src/signal/from-signal.d.ts
4
24
  /**
5
25
  * Marks a field of `contextPlugin` as a value that needs the signal of its task: a
package/dist/signal.js CHANGED
@@ -1 +1 @@
1
- import{t as e}from"./onAbort-BgMfspK4.js";import{n as t}from"./promise-CSLpzwSR.js";import{n}from"./bus-DFlmNJ6h.js";import{t as r}from"./signal-field-key-CWT-3OXC.js";const i=e=>({[r]:e}),a=(t,n)=>(r,i,a)=>{let o=a?.signal;if(!o)return t(r,i,a);let s=t(r,n?(e,t)=>(c(),i(e,t)):i,a),c=e({signal:o},s);return()=>{c(),s()}},o=e=>typeof e==`object`&&!!e,s=(e,t)=>{let n=t[0];if(!o(n)||!o(e)&&typeof e!=`function`)return;let r=Reflect.get(n,`signal`);return r instanceof AbortSignal?r:void 0},c=(r,i,a,o)=>{let s=e({signal:r},e=>{for(let{target:t}of i)t.abort(e)});return t(o)?o.then(s,s):n(a)&&s(),o},l=()=>e=>{let t,n,r=e.addContext;e.addContext=e=>{r?.(e),t?.push({target:e}),n?.aborted&&e.abort(n.reason)};let i=e.emit;e.emit=(e,...r)=>{let a=s(e,r);if(!(a||t))return i(e,...r);let o=t,l=n,u=[];t=a?u:void 0,n=a;let d;try{d=i(e,...r)}finally{t=o,n=l}return a?c(a,u,e,d):d};let o=e.on,l=e.once;return e.on=a(o,!1),e.once=a(l,!0),e};export{i as fromSignal,l as signalPlugin};
1
+ import{t as e}from"./onAbort-BgMfspK4.js";import{n as t}from"./promise-CSLpzwSR.js";import{n}from"./bus-DFlmNJ6h.js";import{t as r}from"./signal-field-key-CWT-3OXC.js";const i=e=>(t,n)=>{let r=n?.signal,i=r?AbortSignal.any([e,r]):e;return fetch(t,{...n,signal:i})},a=e=>({[r]:e}),o=(t,n)=>(r,i,a)=>{let o=a?.signal;if(!o)return t(r,i,a);let s=t(r,n?(e,t)=>(c(),i(e,t)):i,a),c=e({signal:o},s);return()=>{c(),s()}},s=e=>typeof e==`object`&&!!e,c=(e,t)=>{let n=t[0];if(!s(n)||!s(e)&&typeof e!=`function`)return;let r=Reflect.get(n,`signal`);return r instanceof AbortSignal?r:void 0},l=(r,i,a,o)=>{let s=e({signal:r},e=>{for(let{target:t}of i)t.abort(e)});return t(o)?o.then(s,s):n(a)&&s(),o},u=()=>e=>{let t,n,r=e.addContext;e.addContext=e=>{r?.(e),t?.push({target:e}),n?.aborted&&e.abort(n.reason)};let i=e.emit;e.emit=(e,...r)=>{let a=c(e,r);if(!(a||t))return i(e,...r);let o=t,s=n,u=[];t=a?u:void 0,n=a;let d;try{d=i(e,...r)}finally{t=o,n=s}return a?l(a,u,e,d):d};let a=e.on,s=e.once;return e.on=o(a,!1),e.once=o(s,!0),e};export{a as fromSignal,i as signalFetch,u as signalPlugin};
@@ -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
package/docs/context.md CHANGED
@@ -99,6 +99,7 @@ bus.on("user/open", async (id, ctx) => {
99
99
  });
100
100
  ```
101
101
 
102
+ - `signalFetch` from the same entry is a `fetch` bound to a signal, so `fetch: fromSignal(signalFetch)` gives every handler a `ctx.fetch` that stops with the task. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
102
103
  - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
103
104
  - A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
104
105
  - The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
@@ -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,7 +278,68 @@ 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
- - A chain that repeats on purpose is reported. List its event in `ignore`.
283
- - The report is made once for each loop. `turns` in `graph.cycles()` keeps counting.
309
+ ### Code that repeats on purpose looks like a loop
310
+
311
+ `graphLogger` calls it a loop when one event stands in a chain three times. Some code does that by design. A handler that walks a tree emits `tree/visit` for every child, so the same event is in the chain once for each level. The logger cannot tell this from a mistake, so it reports it.
312
+
313
+ Tell it with `ignore`. The event stays in the graph and in the exports, but it is not counted as a repeat, a step, or a storm:
314
+
315
+ ```js
316
+ // graph.js
317
+ import { graphLogger } from "nanoeffects/trace";
318
+
319
+ export const graph = graphLogger({
320
+ // tree/visit repeats once for each level of the tree, on purpose
321
+ ignore: ["tree/visit"],
322
+ });
323
+ ```
324
+
325
+ Do not list an event only to silence a real loop: the report is the point. `ignore` also takes a function of the name, `ignore: (name) => name.startsWith("tree/")`.
326
+
327
+ ### A loop is reported once, and keeps being counted
328
+
329
+ A loop that runs 500 turns writes one warning, not 500. After the warning, the logger counts the turns quietly. Read the count from `graph.cycles()` to see whether the loop is still going:
330
+
331
+ ```js
332
+ // turns.js
333
+ import { graph } from "./graph";
334
+
335
+ const [loop] = graph.cycles();
336
+
337
+ // turns — how many times the loop has gone round since it was found, the first time included
338
+ console.log(loop.path.join(" → "), loop.turns);
339
+ ```
340
+
341
+ Read it twice a moment apart. A number that grew means the loop is still running. The same number means it stopped. To act on the first report in code, use `onCycle`; it is called once too.
342
+
343
+ ### Without tracePlugin the graph is coarser
344
+
345
+ Without `tracePlugin` the logger sees handlers but not the emits that nobody listens to, and it cannot give a name to a listener. A loop is still found, by event type.
@@ -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
 
@@ -7,6 +7,7 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
7
7
  ## On this page
8
8
 
9
9
  - [Know what the signal does](#know-what-the-signal-does)
10
+ - [Bind the signal once](#bind-the-signal-once)
10
11
  - [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
11
12
  - [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
12
13
  - [Turn on the checks](#turn-on-the-checks)
@@ -19,6 +20,44 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
19
20
 
20
21
  `ctx.signal` is an `AbortSignal`. The policy aborts it when the call is cancelled (a newer call of `latest`, a `debounce` that was replaced, an unmounted module). A step stops when you give it the signal: `fetch(url, { signal: ctx.signal })`, `api.search(query, { signal: ctx.signal })`, or the whole `ctx` for an effect, `retry(ctx, ...)`. Everything else keeps running. The bus drops what a cancelled handler emits with `ctx.emit`, so the loss shows up late and quietly.
21
22
 
23
+ ## Bind the signal once
24
+
25
+ The surest fix is a handler with no signal in it. Make the client from the signal of the task, once, and the handler calls `ctx.fetch` and cannot forget anything. `signalFetch` is a `fetch` bound to a signal; `fromSignal` builds it for each task.
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 is made from the signal of the task that reads it
35
+ export const bus = createBus().use(
36
+ contextPlugin({
37
+ fetch: fromSignal(signalFetch),
38
+ // your own client too: it is made from the signal of each task
39
+ http: fromSignal((signal) => axios.create({ signal })),
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
+ // no signal here: ctx.fetch stops when the policy cancels the handler
53
+ const response = await ctx.fetch(`/api?q=${query}`);
54
+ ctx.emit("results", await response.json());
55
+ }),
56
+ );
57
+ ```
58
+
59
+ A call with a `signal` of its own stops with whichever aborts first. Then make the linter hold the line: with `requireContext: true`, every call that a handler waits for must go through `ctx` or be given `ctx`, so a global `fetch` or `api.search(...)` is an error even when the signal is passed by hand ([turn on the checks](#turn-on-the-checks)).
60
+
22
61
  ## Fix a handler that loses the signal
23
62
 
24
63
  The two shapes you meet first, and the fix:
@@ -66,7 +105,7 @@ Each row is a situation where the signal is easy to lose. The right column says
66
105
  | Situation | What to do | Lint | devPlugin |
67
106
  | --------- | ---------- | :--: | :-------: |
68
107
  | The handler has no `ctx`: `latest(async (query) => ...)` | Take `(query, ctx)` and pass `ctx.signal` | yes | |
69
- | `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request | yes | yes |
108
+ | `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request, or bind it once: `ctx.fetch` ([above](#bind-the-signal-once)) | yes | yes |
70
109
  | One step passes the signal, another does not | Pass it to every step | yes | yes |
71
110
  | The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
72
111
  | Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
@@ -77,12 +116,14 @@ Each row is a situation where the signal is easy to lose. The right column says
77
116
  | A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
78
117
  | A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
79
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 | |
80
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 | |
81
121
  | Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
82
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 | |
83
123
  | A function takes the signal and never uses it: `search(query, options) { return fetch(url); }` | Pass the signal on inside it. Only type information finds this in the linter; a test finds it for sure | types | |
84
124
  | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
85
125
  | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
126
+ | A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx.fetch` | | yes |
86
127
  | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
87
128
  | `ctx.on` after the cancel | The same | | yes |
88
129
 
@@ -155,7 +196,7 @@ export default [
155
196
  "nanoeffects/policy-handler-uses-signal": [
156
197
  "warn",
157
198
  // calls that take no signal and are fine to wait for
158
- { ignore: ["cache.get", "storage.*"] },
199
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
159
200
  ],
160
201
  },
161
202
  },
@@ -178,11 +219,12 @@ bus.use(devPlugin());
178
219
 
179
220
  ## Choose which calls are checked
180
221
 
181
- The rule cannot tell, from the call alone, whether the function takes a signal. Three ways to tell it:
222
+ The rule cannot tell, from the call alone, whether the function takes a signal. Ways to tell it:
182
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.
183
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.
184
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.
185
- - **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.
186
228
 
187
229
  <!-- docs-check: skip - a config file of the reader's project -->
188
230
  ```js
@@ -237,7 +279,31 @@ test("a newer search leaves no lost signals", async () => {
237
279
 
238
280
  `getLostSignals(bus)` gives the list instead of the count: `{ kind: "emit" | "on", handler, event }` for each, so a failing test says which handler and which event. Without `devPlugin` (or with `abandoned: false`) it throws and says so. In a production build both functions are empty: the count is 0.
239
281
 
240
- It counts what a handler does after the cancel. A request that a handler started and that the cancel did not stop is not counted unless the handler then emits; the next section finds those.
282
+ On Node (tests, servers) it counts requests too. `devPlugin` follows the `fetch` calls that a handler makes, also after `await`, and counts one with `kind: "request"` when its task was cancelled while the request was still running and the request did not stop: it got no signal, or a signal that is not the task's. You add nothing to the test. In a browser there is no way to tell which task made a request, so only the emits and subscriptions are counted. `requests: false` turns the tracking off.
283
+
284
+ ```js
285
+ import { createBus } from "nanoeffects";
286
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
287
+ import { latest } from "nanoeffects/policies";
288
+
289
+ const bus = createBus();
290
+ bus.use(devPlugin({ log: () => {} }));
291
+
292
+ bus.on(
293
+ "search",
294
+ latest(async (query) => {
295
+ await fetch(`/api?q=${query}`); // no signal
296
+ }),
297
+ );
298
+
299
+ bus.emit("search", "a");
300
+ bus.emit("search", "ab");
301
+
302
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
303
+ console.log(await getLostSignals(bus, { wait: 50 }));
304
+ ```
305
+
306
+ A request that finished before the cancel is not counted. For another client (axios, a driver) bind it with `fromSignal`, or stub it in a test as the next section does for `fetch`.
241
307
 
242
308
  ## Prove it with a test
243
309
 
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
  ## Пауза в наборе текста
@@ -99,6 +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
103
  - `make` запускается при первом чтении, поэтому обработчик, который никогда не использует `ctx.http`, ничего за него не платит.
103
104
  - Задача, которую запускает `ctx.run`, строит своё значение из своего сигнала, так что прерывание дочерней задачи останавливает только её запросы.
104
105
  - Поле не копируется через `{ ...ctx }`, и `Object.keys(ctx)` его не перечисляет. Если вы копируете контекст, сначала прочитайте `ctx.http`.
@@ -16,6 +16,7 @@
16
16
  - [Обрыв цикла во время работы](#обрыв-цикла-во-время-работы)
17
17
  - [Падение теста при цикле](#падение-теста-при-цикле)
18
18
  - [Экспорт графа и payload](#экспорт-графа-и-payload)
19
+ - [Как находится цикл](#как-находится-цикл)
19
20
  - [Ограничения](#ограничения)
20
21
 
21
22
  ## Симптомы
@@ -277,7 +278,68 @@ 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
- - Цепочка, которая повторяется намеренно, попадает в отчёт. Укажите её событие в `ignore`.
283
- - Отчёт пишется один раз на цикл. `turns` в `graph.cycles()` продолжает расти.
309
+ ### Намеренный повтор выглядит как цикл
310
+
311
+ `graphLogger` считает циклом событие, которое встретилось в одной цепочке три раза. Некоторый код делает так намеренно. Обработчик, который обходит дерево, отправляет `tree/visit` для каждого потомка, поэтому одно и то же событие стоит в цепочке по разу на каждый уровень. Отличить это от ошибки логгер не может, поэтому сообщает о цикле.
312
+
313
+ Скажите ему об этом через `ignore`. Событие остаётся в графе и в экспорте, но не считается ни повтором, ни шагом, ни штормом:
314
+
315
+ ```js
316
+ // graph.js
317
+ import { graphLogger } from "nanoeffects/trace";
318
+
319
+ export const graph = graphLogger({
320
+ // tree/visit повторяется на каждом уровне дерева намеренно
321
+ ignore: ["tree/visit"],
322
+ });
323
+ ```
324
+
325
+ Не добавляйте событие в список только ради тишины при настоящем цикле: отчёт нужен именно для него. `ignore` принимает и функцию от имени: `ignore: (name) => name.startsWith("tree/")`.
326
+
327
+ ### О цикле сообщается один раз, но он продолжает считаться
328
+
329
+ Цикл на 500 оборотов пишет одно предупреждение, а не 500. После предупреждения логгер молча считает обороты. Чтобы узнать, идёт ли цикл до сих пор, прочитайте счётчик из `graph.cycles()`:
330
+
331
+ ```js
332
+ // turns.js
333
+ import { graph } from "./graph";
334
+
335
+ const [loop] = graph.cycles();
336
+
337
+ // turns — сколько раз цикл прошёл с момента обнаружения, первый раз включительно
338
+ console.log(loop.path.join(" → "), loop.turns);
339
+ ```
340
+
341
+ Прочитайте его дважды с паузой. Если число выросло, цикл ещё идёт. Если осталось тем же, он остановился. Чтобы отреагировать на первое сообщение в коде, используйте `onCycle`: он тоже вызывается один раз.
342
+
343
+ ### Без tracePlugin граф грубее
344
+
345
+ Без `tracePlugin` логгер видит обработчики, но не видит emit, которые никто не слушает, и не может назвать подписку. Цикл всё равно находится, по типу события.
@@ -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
 
@@ -7,6 +7,7 @@
7
7
  ## На этой странице
8
8
 
9
9
  - [Что делает сигнал](#что-делает-сигнал)
10
+ - [Привязка сигнала один раз](#привязка-сигнала-один-раз)
10
11
  - [Исправление обработчика, который теряет сигнал](#исправление-обработчика-который-теряет-сигнал)
11
12
  - [Передача сигнала в сложных случаях](#передача-сигнала-в-сложных-случаях)
12
13
  - [Включение проверок](#включение-проверок)
@@ -19,6 +20,44 @@
19
20
 
20
21
  `ctx.signal` — это `AbortSignal`. Политика прерывает его, когда вызов отменяют (новый вызов `latest`, замена в `debounce`, размонтированный модуль). Шаг останавливается, когда вы передаёте ему сигнал: `fetch(url, { signal: ctx.signal })`, `api.search(query, { signal: ctx.signal })` или весь `ctx` для эффекта, `retry(ctx, ...)`. Всё остальное продолжает работать. Шина отбрасывает то, что отменённый обработчик отправляет через `ctx.emit`, поэтому потеря проявляется поздно и тихо.
21
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
+
22
61
  ## Исправление обработчика, который теряет сигнал
23
62
 
24
63
  Две формы, которые встречаются первыми, и исправление:
@@ -66,7 +105,7 @@ bus.on(
66
105
  | Ситуация | Что сделать | Линтер | devPlugin |
67
106
  | -------- | ----------- | :----: | :-------: |
68
107
  | У обработчика нет `ctx`: `latest(async (query) => ...)` | Возьмите `(query, ctx)` и передайте `ctx.signal` | да | |
69
- | `ctx` взят, но в запрос не передан | Передайте `{ signal: ctx.signal }` в каждый запрос | да | да |
108
+ | `ctx` взят, но в запрос не передан | Передайте `{ signal: ctx.signal }` в каждый запрос или привяжите его один раз: `ctx.fetch` ([выше](#привязка-сигнала-один-раз)) | да | да |
70
109
  | Один шаг передаёт сигнал, другой нет | Передайте его каждому шагу | да | да |
71
110
  | Сигнал лежит в переменной: `const options = { signal: ctx.signal }` | Ничего: переменная с сигналом считается | принимает | |
72
111
  | Вызовы идут вместе: `Promise.all([a(), b()])` | Передайте сигнал каждому вызову, а не одному | да | |
@@ -77,12 +116,14 @@ bus.on(
77
116
  | Пауза: `await new Promise((r) => setTimeout(r, 1000))` | Используйте `delay(ctx, 1000)` | да | |
78
117
  | Поток: `for await (const x of api.stream(q))` | Передайте сигнал в `api.stream` | да | |
79
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 })`) | да | |
80
120
  | Чужой сигнал: `AbortSignal.timeout(5000)` или сигнал вашего `AbortController` | Объедините его с сигналом обработчика: `AbortSignal.any([ctx.signal, AbortSignal.timeout(5000)])` | да | |
81
121
  | Чтение тела ответа: `await response.json()` | Ничего: сигнал запроса останавливает и его | принимает | |
82
122
  | Библиотека, которая не принимает сигнал (кэш, локальная база) | Укажите её в `ignore` или проверяйте только вызовы из своего списка через `only`. С информацией о типах правило само пропускает функцию без сигнала | `ignore`, `only`, типы | |
83
123
  | Функция принимает сигнал и не использует его: `search(query, options) { return fetch(url); }` | Передайте сигнал дальше внутри неё. В линтере это находит только информация о типах; тест находит наверняка | типы | |
84
124
  | Сигнал подключён вручную: `signal.addEventListener("abort", ...)` | Ничего: такой обработчик не проверяется по шагам | принимает | |
85
125
  | `bus.emit` в обработчике | Используйте `ctx.emit`: только его отбрасывают после отмены обработчика | да | |
126
+ | Запрос продолжает идти после отмены, на Node | Передайте сигнал или используйте `ctx.fetch` | | да |
86
127
  | Результаты отправлены после отмены | Обработчик проигнорировал сигнал. Передайте его или проверьте `ctx.signal.aborted` | | да |
87
128
  | `ctx.on` после отмены | То же | | да |
88
129
 
@@ -155,7 +196,7 @@ export default [
155
196
  "nanoeffects/policy-handler-uses-signal": [
156
197
  "warn",
157
198
  // вызовы без сигнала, которых можно просто дождаться
158
- { ignore: ["cache.get", "storage.*"] },
199
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
159
200
  ],
160
201
  },
161
202
  },
@@ -178,11 +219,12 @@ bus.use(devPlugin());
178
219
 
179
220
  ## Выбор проверяемых вызовов
180
221
 
181
- Правило не может понять по одному вызову, принимает ли функция сигнал. Есть три способа это ему сказать:
222
+ Правило не может понять по одному вызову, принимает ли функция сигнал. Есть несколько способов это ему сказать:
182
223
 
224
+ - **`requireContext`** списка не требует: включите `true`, если обработчики делают запросы через `ctx` (см. [Привязка сигнала один раз](#привязка-сигнала-один-раз)), и любой вызов не через `ctx` попадёт в отчёт. `ignore` по-прежнему пропускает вызов.
183
225
  - **`ignore`** перечисляет вызовы, которые нужно пропустить, по имени: `ignore: ["cache.get", "storage.*"]`. Подходит, когда большинство вызовов принимает сигнал, а несколько не могут.
184
226
  - **`only`** перечисляет вызовы, которые нужно проверять, по имени: `only: ["api.*", "fetch"]`. Всё остальное правило не трогает, так что вызов не из списка никогда не попадёт в отчёт. Подходит, когда вы знаете, какие клиенты принимают сигнал. Вызов из обоих списков пропускается, а пауза, которую нельзя отменить (`await new Promise(...)`), попадает в отчёт независимо от `only`.
185
- - **Информация о типах** списков не требует. С `@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` попадает в отчёт, как раньше.
186
228
 
187
229
  <!-- docs-check: skip - файл настройки проекта читателя -->
188
230
  ```js
@@ -237,7 +279,31 @@ test("a newer search leaves no lost signals", async () => {
237
279
 
238
280
  `getLostSignals(bus)` возвращает не число, а список: `{ kind: "emit" | "on", handler, event }` для каждого, так что упавший тест называет обработчик и событие. Без `devPlugin` (или с `abandoned: false`) функция бросает ошибку и говорит об этом. В production-сборке обе функции пусты: счётчик равен 0.
239
281
 
240
- Считается то, что обработчик делает после отмены. Запрос, который обработчик начал и который отмена не остановила, не считается, пока обработчик потом ничего не отправит; такие запросы находит следующий раздел.
282
+ На Node (тесты, серверы) считаются и запросы. `devPlugin` следит за вызовами `fetch`, которые делает обработчик, в том числе после `await`, и записывает запрос как `kind: "request"`, если его задачу отменили, пока запрос ещё шёл, и запрос не остановился: у него не было сигнала или сигнал не принадлежит задаче. В тест добавлять ничего не нужно. В браузере узнать, какая задача сделала запрос, нельзя, поэтому там считаются только emit и подписки. `requests: false` отключает слежение.
283
+
284
+ ```js
285
+ import { createBus } from "nanoeffects";
286
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
287
+ import { latest } from "nanoeffects/policies";
288
+
289
+ const bus = createBus();
290
+ bus.use(devPlugin({ log: () => {} }));
291
+
292
+ bus.on(
293
+ "search",
294
+ latest(async (query) => {
295
+ await fetch(`/api?q=${query}`); // сигнала нет
296
+ }),
297
+ );
298
+
299
+ bus.emit("search", "a");
300
+ bus.emit("search", "ab");
301
+
302
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
303
+ console.log(await getLostSignals(bus, { wait: 50 }));
304
+ ```
305
+
306
+ Запрос, который закончился до отмены, не считается. Другого клиента (axios, драйвер) привяжите через `fromSignal` или подмените в тесте, как следующий раздел делает для `fetch`.
241
307
 
242
308
  ## Проверка тестом
243
309
 
@@ -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
@@ -5656,6 +5658,7 @@ bus.on("user/open", async (id, ctx) => {
5656
5658
  });
5657
5659
  ```
5658
5660
 
5661
+ - `signalFetch` from the same entry is a `fetch` bound to a signal, so `fetch: fromSignal(signalFetch)` gives every handler a `ctx.fetch` that stops with the task. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
5659
5662
  - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
5660
5663
  - A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
5661
5664
  - The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
@@ -7353,6 +7356,7 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
7353
7356
  ## On this page
7354
7357
 
7355
7358
  - [Know what the signal does](#know-what-the-signal-does)
7359
+ - [Bind the signal once](#bind-the-signal-once)
7356
7360
  - [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
7357
7361
  - [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
7358
7362
  - [Turn on the checks](#turn-on-the-checks)
@@ -7365,6 +7369,44 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
7365
7369
 
7366
7370
  `ctx.signal` is an `AbortSignal`. The policy aborts it when the call is cancelled (a newer call of `latest`, a `debounce` that was replaced, an unmounted module). A step stops when you give it the signal: `fetch(url, { signal: ctx.signal })`, `api.search(query, { signal: ctx.signal })`, or the whole `ctx` for an effect, `retry(ctx, ...)`. Everything else keeps running. The bus drops what a cancelled handler emits with `ctx.emit`, so the loss shows up late and quietly.
7367
7371
 
7372
+ ## Bind the signal once
7373
+
7374
+ The surest fix is a handler with no signal in it. Make the client from the signal of the task, once, and the handler calls `ctx.fetch` and cannot forget anything. `signalFetch` is a `fetch` bound to a signal; `fromSignal` builds it for each task.
7375
+
7376
+ ```js
7377
+ // bus.js
7378
+ import { createBus } from "nanoeffects";
7379
+ import { contextPlugin } from "nanoeffects/context";
7380
+ import { fromSignal, signalFetch } from "nanoeffects/signal";
7381
+ import axios from "axios";
7382
+
7383
+ // fromSignal — ctx.fetch is made from the signal of the task that reads it
7384
+ export const bus = createBus().use(
7385
+ contextPlugin({
7386
+ fetch: fromSignal(signalFetch),
7387
+ // your own client too: it is made from the signal of each task
7388
+ http: fromSignal((signal) => axios.create({ signal })),
7389
+ }),
7390
+ );
7391
+ ```
7392
+
7393
+ ```js
7394
+ // search-handlers.js
7395
+ import { latest } from "nanoeffects/policies";
7396
+ import { bus } from "./bus";
7397
+
7398
+ bus.on(
7399
+ "search",
7400
+ latest(async (query, ctx) => {
7401
+ // no signal here: ctx.fetch stops when the policy cancels the handler
7402
+ const response = await ctx.fetch(`/api?q=${query}`);
7403
+ ctx.emit("results", await response.json());
7404
+ }),
7405
+ );
7406
+ ```
7407
+
7408
+ A call with a `signal` of its own stops with whichever aborts first. Then make the linter hold the line: with `requireContext: true`, every call that a handler waits for must go through `ctx` or be given `ctx`, so a global `fetch` or `api.search(...)` is an error even when the signal is passed by hand ([turn on the checks](#turn-on-the-checks)).
7409
+
7368
7410
  ## Fix a handler that loses the signal
7369
7411
 
7370
7412
  The two shapes you meet first, and the fix:
@@ -7412,7 +7454,7 @@ Each row is a situation where the signal is easy to lose. The right column says
7412
7454
  | Situation | What to do | Lint | devPlugin |
7413
7455
  | --------- | ---------- | :--: | :-------: |
7414
7456
  | The handler has no `ctx`: `latest(async (query) => ...)` | Take `(query, ctx)` and pass `ctx.signal` | yes | |
7415
- | `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request | yes | yes |
7457
+ | `ctx` is taken and never used for the request | Pass `{ signal: ctx.signal }` to each request, or bind it once: `ctx.fetch` ([above](#bind-the-signal-once)) | yes | yes |
7416
7458
  | One step passes the signal, another does not | Pass it to every step | yes | yes |
7417
7459
  | The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
7418
7460
  | Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
@@ -7423,12 +7465,14 @@ Each row is a situation where the signal is easy to lose. The right column says
7423
7465
  | A pause: `await new Promise((r) => setTimeout(r, 1000))` | Use `delay(ctx, 1000)` | yes | |
7424
7466
  | A stream: `for await (const x of api.stream(q))` | Pass the signal to `api.stream` | yes | |
7425
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 | |
7426
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 | |
7427
7470
  | Reading a response body: `await response.json()` | Nothing, the signal of the request stops it | accepted | |
7428
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 | |
7429
7472
  | A function takes the signal and never uses it: `search(query, options) { return fetch(url); }` | Pass the signal on inside it. Only type information finds this in the linter; a test finds it for sure | types | |
7430
7473
  | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
7431
7474
  | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
7475
+ | A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx.fetch` | | yes |
7432
7476
  | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
7433
7477
  | `ctx.on` after the cancel | The same | | yes |
7434
7478
 
@@ -7501,7 +7545,7 @@ export default [
7501
7545
  "nanoeffects/policy-handler-uses-signal": [
7502
7546
  "warn",
7503
7547
  // calls that take no signal and are fine to wait for
7504
- { ignore: ["cache.get", "storage.*"] },
7548
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
7505
7549
  ],
7506
7550
  },
7507
7551
  },
@@ -7524,11 +7568,12 @@ bus.use(devPlugin());
7524
7568
 
7525
7569
  ## Choose which calls are checked
7526
7570
 
7527
- The rule cannot tell, from the call alone, whether the function takes a signal. Three ways to tell it:
7571
+ The rule cannot tell, from the call alone, whether the function takes a signal. Ways to tell it:
7528
7572
 
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.
7529
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.
7530
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.
7531
- - **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.
7532
7577
 
7533
7578
  <!-- docs-check: skip - a config file of the reader's project -->
7534
7579
  ```js
@@ -7583,7 +7628,31 @@ test("a newer search leaves no lost signals", async () => {
7583
7628
 
7584
7629
  `getLostSignals(bus)` gives the list instead of the count: `{ kind: "emit" | "on", handler, event }` for each, so a failing test says which handler and which event. Without `devPlugin` (or with `abandoned: false`) it throws and says so. In a production build both functions are empty: the count is 0.
7585
7630
 
7586
- It counts what a handler does after the cancel. A request that a handler started and that the cancel did not stop is not counted unless the handler then emits; the next section finds those.
7631
+ On Node (tests, servers) it counts requests too. `devPlugin` follows the `fetch` calls that a handler makes, also after `await`, and counts one with `kind: "request"` when its task was cancelled while the request was still running and the request did not stop: it got no signal, or a signal that is not the task's. You add nothing to the test. In a browser there is no way to tell which task made a request, so only the emits and subscriptions are counted. `requests: false` turns the tracking off.
7632
+
7633
+ ```js
7634
+ import { createBus } from "nanoeffects";
7635
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
7636
+ import { latest } from "nanoeffects/policies";
7637
+
7638
+ const bus = createBus();
7639
+ bus.use(devPlugin({ log: () => {} }));
7640
+
7641
+ bus.on(
7642
+ "search",
7643
+ latest(async (query) => {
7644
+ await fetch(`/api?q=${query}`); // no signal
7645
+ }),
7646
+ );
7647
+
7648
+ bus.emit("search", "a");
7649
+ bus.emit("search", "ab");
7650
+
7651
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
7652
+ console.log(await getLostSignals(bus, { wait: 50 }));
7653
+ ```
7654
+
7655
+ A request that finished before the cancel is not counted. For another client (axios, a driver) bind it with `fromSignal`, or stub it in a test as the next section does for `fetch`.
7587
7656
 
7588
7657
  ## Prove it with a test
7589
7658
 
@@ -8117,6 +8186,7 @@ A loop is two handlers that emit each other's events, or one handler that emits
8117
8186
  - [Cut a loop at run time](#cut-a-loop-at-run-time)
8118
8187
  - [Fail a test on a loop](#fail-a-test-on-a-loop)
8119
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)
8120
8190
  - [Know the limits](#know-the-limits)
8121
8191
 
8122
8192
  ## Start from the symptom
@@ -8378,10 +8448,71 @@ console.log(JSON.stringify(data));
8378
8448
 
8379
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.
8380
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
+
8381
8477
  ## Know the limits
8382
8478
 
8383
- - A chain that repeats on purpose is reported. List its event in `ignore`.
8384
- - The report is made once for each loop. `turns` in `graph.cycles()` keeps counting.
8479
+ ### Code that repeats on purpose looks like a loop
8480
+
8481
+ `graphLogger` calls it a loop when one event stands in a chain three times. Some code does that by design. A handler that walks a tree emits `tree/visit` for every child, so the same event is in the chain once for each level. The logger cannot tell this from a mistake, so it reports it.
8482
+
8483
+ Tell it with `ignore`. The event stays in the graph and in the exports, but it is not counted as a repeat, a step, or a storm:
8484
+
8485
+ ```js
8486
+ // graph.js
8487
+ import { graphLogger } from "nanoeffects/trace";
8488
+
8489
+ export const graph = graphLogger({
8490
+ // tree/visit repeats once for each level of the tree, on purpose
8491
+ ignore: ["tree/visit"],
8492
+ });
8493
+ ```
8494
+
8495
+ Do not list an event only to silence a real loop: the report is the point. `ignore` also takes a function of the name, `ignore: (name) => name.startsWith("tree/")`.
8496
+
8497
+ ### A loop is reported once, and keeps being counted
8498
+
8499
+ A loop that runs 500 turns writes one warning, not 500. After the warning, the logger counts the turns quietly. Read the count from `graph.cycles()` to see whether the loop is still going:
8500
+
8501
+ ```js
8502
+ // turns.js
8503
+ import { graph } from "./graph";
8504
+
8505
+ const [loop] = graph.cycles();
8506
+
8507
+ // turns — how many times the loop has gone round since it was found, the first time included
8508
+ console.log(loop.path.join(" → "), loop.turns);
8509
+ ```
8510
+
8511
+ Read it twice a moment apart. A number that grew means the loop is still running. The same number means it stopped. To act on the first report in code, use `onCycle`; it is called once too.
8512
+
8513
+ ### Without tracePlugin the graph is coarser
8514
+
8515
+ Without `tracePlugin` the logger sees handlers but not the emits that nobody listens to, and it cannot give a name to a listener. A loop is still found, by event type.
8385
8516
 
8386
8517
  ---
8387
8518
  Source: docs/loop-guard.md
@@ -8400,6 +8531,7 @@ Use it as a safety net, also in production. It needs no tracer and changes nothi
8400
8531
  - [Choose what happens at the limit](#choose-what-happens-at-the-limit)
8401
8532
  - [Limit the emits that come from outside](#limit-the-emits-that-come-from-outside)
8402
8533
  - [Leave a recursion out](#leave-a-recursion-out)
8534
+ - [Know what it costs](#know-what-it-costs)
8403
8535
  - [Know what it does not do](#know-what-it-does-not-do)
8404
8536
 
8405
8537
  ## Cut a loop
@@ -8490,6 +8622,18 @@ export const bus = createBus().use(
8490
8622
  );
8491
8623
  ```
8492
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
+
8493
8637
  ## Know what it does not do
8494
8638
 
8495
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.30.1",
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",
@@ -242,7 +242,7 @@
242
242
  {
243
243
  "name": "signal (plugin)",
244
244
  "path": "dist/signal.js",
245
- "limit": "0.7 kB"
245
+ "limit": "0.8 kB"
246
246
  },
247
247
  {
248
248
  "name": "context (plugin)",
@@ -307,7 +307,7 @@
307
307
  {
308
308
  "name": "dev",
309
309
  "path": "dist/dev.js",
310
- "limit": "1.6 kB"
310
+ "limit": "2.2 kB"
311
311
  },
312
312
  {
313
313
  "name": "dev (production)",