nanoeffects 0.30.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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=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};
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.
@@ -279,5 +279,40 @@ A payload can be a class instance, a `Map`, a circular object or an `Error`, whi
279
279
 
280
280
  ## Know the limits
281
281
 
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.
282
+ ### Code that repeats on purpose looks like a loop
283
+
284
+ `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.
285
+
286
+ 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:
287
+
288
+ ```js
289
+ // graph.js
290
+ import { graphLogger } from "nanoeffects/trace";
291
+
292
+ export const graph = graphLogger({
293
+ // tree/visit repeats once for each level of the tree, on purpose
294
+ ignore: ["tree/visit"],
295
+ });
296
+ ```
297
+
298
+ 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/")`.
299
+
300
+ ### A loop is reported once, and keeps being counted
301
+
302
+ 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:
303
+
304
+ ```js
305
+ // turns.js
306
+ import { graph } from "./graph";
307
+
308
+ const [loop] = graph.cycles();
309
+
310
+ // turns — how many times the loop has gone round since it was found, the first time included
311
+ console.log(loop.path.join(" → "), loop.turns);
312
+ ```
313
+
314
+ 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.
315
+
316
+ ### Without tracePlugin the graph is coarser
317
+
318
+ 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.
@@ -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 | |
@@ -83,6 +122,7 @@ Each row is a situation where the signal is easy to lose. The right column says
83
122
  | 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
123
  | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
85
124
  | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
125
+ | A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx.fetch` | | yes |
86
126
  | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
87
127
  | `ctx.on` after the cancel | The same | | yes |
88
128
 
@@ -155,7 +195,7 @@ export default [
155
195
  "nanoeffects/policy-handler-uses-signal": [
156
196
  "warn",
157
197
  // calls that take no signal and are fine to wait for
158
- { ignore: ["cache.get", "storage.*"] },
198
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
159
199
  ],
160
200
  },
161
201
  },
@@ -178,8 +218,9 @@ bus.use(devPlugin());
178
218
 
179
219
  ## Choose which calls are checked
180
220
 
181
- The rule cannot tell, from the call alone, whether the function takes a signal. Three ways to tell it:
221
+ The rule cannot tell, from the call alone, whether the function takes a signal. Ways to tell it:
182
222
 
223
+ - **`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
224
  - **`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
225
  - **`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
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.
@@ -237,7 +278,31 @@ test("a newer search leaves no lost signals", async () => {
237
278
 
238
279
  `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
280
 
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.
281
+ 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.
282
+
283
+ ```js
284
+ import { createBus } from "nanoeffects";
285
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
286
+ import { latest } from "nanoeffects/policies";
287
+
288
+ const bus = createBus();
289
+ bus.use(devPlugin({ log: () => {} }));
290
+
291
+ bus.on(
292
+ "search",
293
+ latest(async (query) => {
294
+ await fetch(`/api?q=${query}`); // no signal
295
+ }),
296
+ );
297
+
298
+ bus.emit("search", "a");
299
+ bus.emit("search", "ab");
300
+
301
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
302
+ console.log(await getLostSignals(bus, { wait: 50 }));
303
+ ```
304
+
305
+ 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
306
 
242
307
  ## Prove it with a test
243
308
 
@@ -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`.
@@ -279,5 +279,40 @@ Payload может быть экземпляром класса, `Map`, объе
279
279
 
280
280
  ## Ограничения
281
281
 
282
- - Цепочка, которая повторяется намеренно, попадает в отчёт. Укажите её событие в `ignore`.
283
- - Отчёт пишется один раз на цикл. `turns` в `graph.cycles()` продолжает расти.
282
+ ### Намеренный повтор выглядит как цикл
283
+
284
+ `graphLogger` считает циклом событие, которое встретилось в одной цепочке три раза. Некоторый код делает так намеренно. Обработчик, который обходит дерево, отправляет `tree/visit` для каждого потомка, поэтому одно и то же событие стоит в цепочке по разу на каждый уровень. Отличить это от ошибки логгер не может, поэтому сообщает о цикле.
285
+
286
+ Скажите ему об этом через `ignore`. Событие остаётся в графе и в экспорте, но не считается ни повтором, ни шагом, ни штормом:
287
+
288
+ ```js
289
+ // graph.js
290
+ import { graphLogger } from "nanoeffects/trace";
291
+
292
+ export const graph = graphLogger({
293
+ // tree/visit повторяется на каждом уровне дерева намеренно
294
+ ignore: ["tree/visit"],
295
+ });
296
+ ```
297
+
298
+ Не добавляйте событие в список только ради тишины при настоящем цикле: отчёт нужен именно для него. `ignore` принимает и функцию от имени: `ignore: (name) => name.startsWith("tree/")`.
299
+
300
+ ### О цикле сообщается один раз, но он продолжает считаться
301
+
302
+ Цикл на 500 оборотов пишет одно предупреждение, а не 500. После предупреждения логгер молча считает обороты. Чтобы узнать, идёт ли цикл до сих пор, прочитайте счётчик из `graph.cycles()`:
303
+
304
+ ```js
305
+ // turns.js
306
+ import { graph } from "./graph";
307
+
308
+ const [loop] = graph.cycles();
309
+
310
+ // turns — сколько раз цикл прошёл с момента обнаружения, первый раз включительно
311
+ console.log(loop.path.join(" → "), loop.turns);
312
+ ```
313
+
314
+ Прочитайте его дважды с паузой. Если число выросло, цикл ещё идёт. Если осталось тем же, он остановился. Чтобы отреагировать на первое сообщение в коде, используйте `onCycle`: он тоже вызывается один раз.
315
+
316
+ ### Без tracePlugin граф грубее
317
+
318
+ Без `tracePlugin` логгер видит обработчики, но не видит emit, которые никто не слушает, и не может назвать подписку. Цикл всё равно находится, по типу события.
@@ -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()])` | Передайте сигнал каждому вызову, а не одному | да | |
@@ -83,6 +122,7 @@ bus.on(
83
122
  | Функция принимает сигнал и не использует его: `search(query, options) { return fetch(url); }` | Передайте сигнал дальше внутри неё. В линтере это находит только информация о типах; тест находит наверняка | типы | |
84
123
  | Сигнал подключён вручную: `signal.addEventListener("abort", ...)` | Ничего: такой обработчик не проверяется по шагам | принимает | |
85
124
  | `bus.emit` в обработчике | Используйте `ctx.emit`: только его отбрасывают после отмены обработчика | да | |
125
+ | Запрос продолжает идти после отмены, на Node | Передайте сигнал или используйте `ctx.fetch` | | да |
86
126
  | Результаты отправлены после отмены | Обработчик проигнорировал сигнал. Передайте его или проверьте `ctx.signal.aborted` | | да |
87
127
  | `ctx.on` после отмены | То же | | да |
88
128
 
@@ -155,7 +195,7 @@ export default [
155
195
  "nanoeffects/policy-handler-uses-signal": [
156
196
  "warn",
157
197
  // вызовы без сигнала, которых можно просто дождаться
158
- { ignore: ["cache.get", "storage.*"] },
198
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
159
199
  ],
160
200
  },
161
201
  },
@@ -178,8 +218,9 @@ bus.use(devPlugin());
178
218
 
179
219
  ## Выбор проверяемых вызовов
180
220
 
181
- Правило не может понять по одному вызову, принимает ли функция сигнал. Есть три способа это ему сказать:
221
+ Правило не может понять по одному вызову, принимает ли функция сигнал. Есть несколько способов это ему сказать:
182
222
 
223
+ - **`requireContext`** списка не требует: включите `true`, если обработчики делают запросы через `ctx` (см. [Привязка сигнала один раз](#привязка-сигнала-один-раз)), и любой вызов не через `ctx` попадёт в отчёт. `ignore` по-прежнему пропускает вызов.
183
224
  - **`ignore`** перечисляет вызовы, которые нужно пропустить, по имени: `ignore: ["cache.get", "storage.*"]`. Подходит, когда большинство вызовов принимает сигнал, а несколько не могут.
184
225
  - **`only`** перечисляет вызовы, которые нужно проверять, по имени: `only: ["api.*", "fetch"]`. Всё остальное правило не трогает, так что вызов не из списка никогда не попадёт в отчёт. Подходит, когда вы знаете, какие клиенты принимают сигнал. Вызов из обоих списков пропускается, а пауза, которую нельзя отменить (`await new Promise(...)`), попадает в отчёт независимо от `only`.
185
226
  - **Информация о типах** списков не требует. С `@typescript-eslint/parser` и `project` правило читает вызываемую функцию. Вызов без сигнала попадает в отчёт, только если функция его принимает (параметр типа `AbortSignal` или объект параметров с `signal`), поэтому `cache.get(key)` правило не трогает. Вызов с сигналом попадает в отчёт, если функция лежит в ваших исходниках и не использует сигнал: параметр не используется или из него читаются только другие параметры. Сигнал, переданный в другую вашу функцию, правило прослеживает до трёх вызовов вглубь, а функцию из библиотеки считает надёжной. Вызываемое с типом `any` попадает в отчёт, как раньше.
@@ -237,7 +278,31 @@ test("a newer search leaves no lost signals", async () => {
237
278
 
238
279
  `getLostSignals(bus)` возвращает не число, а список: `{ kind: "emit" | "on", handler, event }` для каждого, так что упавший тест называет обработчик и событие. Без `devPlugin` (или с `abandoned: false`) функция бросает ошибку и говорит об этом. В production-сборке обе функции пусты: счётчик равен 0.
239
280
 
240
- Считается то, что обработчик делает после отмены. Запрос, который обработчик начал и который отмена не остановила, не считается, пока обработчик потом ничего не отправит; такие запросы находит следующий раздел.
281
+ На Node (тесты, серверы) считаются и запросы. `devPlugin` следит за вызовами `fetch`, которые делает обработчик, в том числе после `await`, и записывает запрос как `kind: "request"`, если его задачу отменили, пока запрос ещё шёл, и запрос не остановился: у него не было сигнала или сигнал не принадлежит задаче. В тест добавлять ничего не нужно. В браузере узнать, какая задача сделала запрос, нельзя, поэтому там считаются только emit и подписки. `requests: false` отключает слежение.
282
+
283
+ ```js
284
+ import { createBus } from "nanoeffects";
285
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
286
+ import { latest } from "nanoeffects/policies";
287
+
288
+ const bus = createBus();
289
+ bus.use(devPlugin({ log: () => {} }));
290
+
291
+ bus.on(
292
+ "search",
293
+ latest(async (query) => {
294
+ await fetch(`/api?q=${query}`); // сигнала нет
295
+ }),
296
+ );
297
+
298
+ bus.emit("search", "a");
299
+ bus.emit("search", "ab");
300
+
301
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
302
+ console.log(await getLostSignals(bus, { wait: 50 }));
303
+ ```
304
+
305
+ Запрос, который закончился до отмены, не считается. Другого клиента (axios, драйвер) привяжите через `fromSignal` или подмените в тесте, как следующий раздел делает для `fetch`.
241
306
 
242
307
  ## Проверка тестом
243
308
 
package/llms-full.txt CHANGED
@@ -5656,6 +5656,7 @@ bus.on("user/open", async (id, ctx) => {
5656
5656
  });
5657
5657
  ```
5658
5658
 
5659
+ - `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
5660
  - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
5660
5661
  - 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
5662
  - 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 +7354,7 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
7353
7354
  ## On this page
7354
7355
 
7355
7356
  - [Know what the signal does](#know-what-the-signal-does)
7357
+ - [Bind the signal once](#bind-the-signal-once)
7356
7358
  - [Fix a handler that loses the signal](#fix-a-handler-that-loses-the-signal)
7357
7359
  - [Pass the signal when it is hard](#pass-the-signal-when-it-is-hard)
7358
7360
  - [Turn on the checks](#turn-on-the-checks)
@@ -7365,6 +7367,44 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
7365
7367
 
7366
7368
  `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
7369
 
7370
+ ## Bind the signal once
7371
+
7372
+ 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.
7373
+
7374
+ ```js
7375
+ // bus.js
7376
+ import { createBus } from "nanoeffects";
7377
+ import { contextPlugin } from "nanoeffects/context";
7378
+ import { fromSignal, signalFetch } from "nanoeffects/signal";
7379
+ import axios from "axios";
7380
+
7381
+ // fromSignal — ctx.fetch is made from the signal of the task that reads it
7382
+ export const bus = createBus().use(
7383
+ contextPlugin({
7384
+ fetch: fromSignal(signalFetch),
7385
+ // your own client too: it is made from the signal of each task
7386
+ http: fromSignal((signal) => axios.create({ signal })),
7387
+ }),
7388
+ );
7389
+ ```
7390
+
7391
+ ```js
7392
+ // search-handlers.js
7393
+ import { latest } from "nanoeffects/policies";
7394
+ import { bus } from "./bus";
7395
+
7396
+ bus.on(
7397
+ "search",
7398
+ latest(async (query, ctx) => {
7399
+ // no signal here: ctx.fetch stops when the policy cancels the handler
7400
+ const response = await ctx.fetch(`/api?q=${query}`);
7401
+ ctx.emit("results", await response.json());
7402
+ }),
7403
+ );
7404
+ ```
7405
+
7406
+ 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)).
7407
+
7368
7408
  ## Fix a handler that loses the signal
7369
7409
 
7370
7410
  The two shapes you meet first, and the fix:
@@ -7412,7 +7452,7 @@ Each row is a situation where the signal is easy to lose. The right column says
7412
7452
  | Situation | What to do | Lint | devPlugin |
7413
7453
  | --------- | ---------- | :--: | :-------: |
7414
7454
  | 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 |
7455
+ | `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
7456
  | One step passes the signal, another does not | Pass it to every step | yes | yes |
7417
7457
  | The signal is in a variable: `const options = { signal: ctx.signal }` | Nothing, a variable that holds it counts | accepted | |
7418
7458
  | Calls run together: `Promise.all([a(), b()])` | Pass it to each call, not only one | yes | |
@@ -7429,6 +7469,7 @@ Each row is a situation where the signal is easy to lose. The right column says
7429
7469
  | 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
7470
  | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
7431
7471
  | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
7472
+ | A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx.fetch` | | yes |
7432
7473
  | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
7433
7474
  | `ctx.on` after the cancel | The same | | yes |
7434
7475
 
@@ -7501,7 +7542,7 @@ export default [
7501
7542
  "nanoeffects/policy-handler-uses-signal": [
7502
7543
  "warn",
7503
7544
  // calls that take no signal and are fine to wait for
7504
- { ignore: ["cache.get", "storage.*"] },
7545
+ { ignore: ["cache.get", "storage.*"], requireContext: false },
7505
7546
  ],
7506
7547
  },
7507
7548
  },
@@ -7524,8 +7565,9 @@ bus.use(devPlugin());
7524
7565
 
7525
7566
  ## Choose which calls are checked
7526
7567
 
7527
- The rule cannot tell, from the call alone, whether the function takes a signal. Three ways to tell it:
7568
+ The rule cannot tell, from the call alone, whether the function takes a signal. Ways to tell it:
7528
7569
 
7570
+ - **`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
7571
  - **`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
7572
  - **`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
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.
@@ -7583,7 +7625,31 @@ test("a newer search leaves no lost signals", async () => {
7583
7625
 
7584
7626
  `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
7627
 
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.
7628
+ 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.
7629
+
7630
+ ```js
7631
+ import { createBus } from "nanoeffects";
7632
+ import { devPlugin, getLostSignals } from "nanoeffects/dev";
7633
+ import { latest } from "nanoeffects/policies";
7634
+
7635
+ const bus = createBus();
7636
+ bus.use(devPlugin({ log: () => {} }));
7637
+
7638
+ bus.on(
7639
+ "search",
7640
+ latest(async (query) => {
7641
+ await fetch(`/api?q=${query}`); // no signal
7642
+ }),
7643
+ );
7644
+
7645
+ bus.emit("search", "a");
7646
+ bus.emit("search", "ab");
7647
+
7648
+ // [{ kind: "request", handler: "search", event: "/api?q=a" }]
7649
+ console.log(await getLostSignals(bus, { wait: 50 }));
7650
+ ```
7651
+
7652
+ 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
7653
 
7588
7654
  ## Prove it with a test
7589
7655
 
@@ -8380,8 +8446,43 @@ A payload can be a class instance, a `Map`, a circular object or an `Error`, whi
8380
8446
 
8381
8447
  ## Know the limits
8382
8448
 
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.
8449
+ ### Code that repeats on purpose looks like a loop
8450
+
8451
+ `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.
8452
+
8453
+ 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:
8454
+
8455
+ ```js
8456
+ // graph.js
8457
+ import { graphLogger } from "nanoeffects/trace";
8458
+
8459
+ export const graph = graphLogger({
8460
+ // tree/visit repeats once for each level of the tree, on purpose
8461
+ ignore: ["tree/visit"],
8462
+ });
8463
+ ```
8464
+
8465
+ 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/")`.
8466
+
8467
+ ### A loop is reported once, and keeps being counted
8468
+
8469
+ 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:
8470
+
8471
+ ```js
8472
+ // turns.js
8473
+ import { graph } from "./graph";
8474
+
8475
+ const [loop] = graph.cycles();
8476
+
8477
+ // turns — how many times the loop has gone round since it was found, the first time included
8478
+ console.log(loop.path.join(" → "), loop.turns);
8479
+ ```
8480
+
8481
+ 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.
8482
+
8483
+ ### Without tracePlugin the graph is coarser
8484
+
8485
+ 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
8486
 
8386
8487
  ---
8387
8488
  Source: docs/loop-guard.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nanoeffects",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
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)",