nanoeffects 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/{action-creator-DprJV-pN.d.ts → action-creator-CXIq6GWK.d.ts} +1 -1
  2. package/dist/actions.d.ts +3 -3
  3. package/dist/{context-types-BiTMp_lW.d.ts → context-types-BO1QE6W2.d.ts} +1 -1
  4. package/dist/context.d.ts +2 -2
  5. package/dist/dev.d.ts +2 -2
  6. package/dist/effects.d.ts +2 -2
  7. package/dist/guard.d.ts +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/match.d.ts +1 -1
  10. package/dist/{module-Fk4OtkZk.d.ts → module-BXNaEXE9.d.ts} +2 -2
  11. package/dist/notrace/{action-creator-BWd9FWt6.d.ts → action-creator-JUwIsFUl.d.ts} +1 -1
  12. package/dist/notrace/actions.d.ts +2 -2
  13. package/dist/notrace/effects.d.ts +2 -2
  14. package/dist/notrace/index.d.ts +1 -1
  15. package/dist/notrace/{plugin-DmAYT3Xk.d.ts → plugin-dLdTM2JL.d.ts} +1 -1
  16. package/dist/notrace/policies.d.ts +158 -2
  17. package/dist/notrace/policies.js +1 -1
  18. package/dist/notrace/redux.d.ts +1 -1
  19. package/dist/observable.d.ts +1 -1
  20. package/dist/oop.d.ts +1 -1
  21. package/dist/{plugin-2d3uuW7o.d.ts → plugin-B8La3K9m.d.ts} +2 -2
  22. package/dist/policies.d.ts +159 -3
  23. package/dist/policies.js +1 -1
  24. package/dist/react.d.ts +2 -2
  25. package/dist/redux.d.ts +1 -1
  26. package/dist/scope.d.ts +2 -2
  27. package/dist/signal.d.ts +12 -11
  28. package/dist/signal.js +1 -1
  29. package/dist/svelte.d.ts +1 -1
  30. package/dist/testing.d.ts +1 -1
  31. package/dist/trace.d.ts +1 -1
  32. package/dist/{types-DxPZDzZG.d.ts → types-ByoeMqVh.d.ts} +2 -2
  33. package/dist/vue.d.ts +1 -1
  34. package/dist/{with-policy-Y12d-yx6.d.ts → with-policy-e4yx1vhN.d.ts} +1 -1
  35. package/docs/README.md +29 -2
  36. package/docs/cancel-stale-requests.md +1 -1
  37. package/docs/context.md +1 -1
  38. package/docs/package.md +2 -2
  39. package/docs/pass-the-signal.md +45 -17
  40. package/docs/policies.md +189 -1
  41. package/docs/react.md +41 -0
  42. package/docs/recipes.md +90 -0
  43. package/docs/ru/README.md +29 -2
  44. package/docs/ru/cancel-stale-requests.md +1 -1
  45. package/docs/ru/context.md +1 -1
  46. package/docs/ru/package.md +2 -2
  47. package/docs/ru/pass-the-signal.md +46 -18
  48. package/docs/ru/policies.md +189 -1
  49. package/docs/ru/react.md +41 -0
  50. package/docs/ru/recipes.md +90 -0
  51. package/docs/ru/svelte.md +41 -0
  52. package/docs/ru/vue.md +45 -0
  53. package/docs/svelte.md +41 -0
  54. package/docs/vue.md +45 -0
  55. package/llms-full.txt +455 -22
  56. package/llms.txt +1 -1
  57. package/package.json +3 -3
package/dist/react.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { H as EventMap, T as EventKey, a as FreeActionCreator, q as Task, t as ActionCreator, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, q as Task, t as ActionCreator, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
- import { t as PolicyFunction } from "./with-policy-Y12d-yx6.js";
3
+ import { t as PolicyFunction } from "./with-policy-e4yx1vhN.js";
4
4
  import { t as StateOf } from "./state-of-C19H6BoV.js";
5
5
  //#region src/react/plugin.d.ts
6
6
  /** Tells whether two values are the same: `Object.is` unless the caller gives another. */
package/dist/redux.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
3
  //#region src/store/redux/middleware.d.ts
4
4
  /** What Redux passes through a middleware: an action is a plain object with a `type`. */
package/dist/scope.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { H as EventMap, I as WithSignal, c as Scope, f as Effect, m as Signaled, p as Effects, s as EmitScope, v as Bus } from "./action-creator-DprJV-pN.js";
2
- import { n as ScopeSlots, r as Scoped } from "./types-DxPZDzZG.js";
1
+ import { H as EventMap, I as WithSignal, c as Scope, f as Effect, m as Signaled, p as Effects, s as EmitScope, v as Bus } from "./action-creator-CXIq6GWK.js";
2
+ import { n as ScopeSlots, r as Scoped } from "./types-ByoeMqVh.js";
3
3
  //#region src/scope/effect.d.ts
4
4
  /**
5
5
  * Marks a function whose first parameter is `{ signal }` as an effect, so that
package/dist/signal.d.ts CHANGED
@@ -1,24 +1,25 @@
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-DprJV-pN.js";
2
- import { n as FromSignal } from "./context-types-BiTMp_lW.js";
1
+ import { A as HandledCall, C as Emit, E as EventLike, H as EventMap, I as WithSignal, s as EmitScope, u as WithScope, v as Bus } from "./action-creator-CXIq6GWK.js";
2
+ import { n as FromSignal } from "./context-types-BO1QE6W2.js";
3
3
  //#region src/signal/fetch.d.ts
4
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.
5
+ * A `fetch` that stops with its task: an effect, so `ctx` is the first argument and the request carries
6
+ * `ctx.signal`. The caller never passes the signal. A call that has a `signal` of its own in `init` stops with
7
+ * whichever aborts first. Outside a task pass `{ signal }` of the source, or `{}` in plain JavaScript.
8
+ *
9
+ * With `effectsPlugin({ fetch: signalFetch })` every task has a `ctx.fetch(input, init)` bound to itself.
8
10
  *
9
11
  * @example
10
12
  * ```ts
11
- * const bus = createBus().use(
12
- * contextPlugin({ fetch: fromSignal(signalFetch) }),
13
- * );
14
- *
15
13
  * bus.on("search", latest(async (query, ctx) => {
16
- * const response = await ctx.fetch(`/api?q=${query}`); // stops when the policy cancels the handler
14
+ * const response = await signalFetch(ctx, `/api?q=${query}`); // stops when the policy cancels the handler
17
15
  * ctx.emit("results", await response.json());
18
16
  * }));
17
+ *
18
+ * const withFetch = createBus().use(effectsPlugin({ fetch: signalFetch }));
19
+ * withFetch.on("search", latest(async (query, { fetch }) => fetch(`/api?q=${query}`)));
19
20
  * ```
20
21
  */
21
- export declare const signalFetch: (signal: AbortSignal) => typeof fetch;
22
+ export declare const signalFetch: (ctx: WithSignal, input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
22
23
  //#endregion
23
24
  //#region src/signal/from-signal.d.ts
24
25
  /**
package/dist/signal.js CHANGED
@@ -1 +1 @@
1
- import{i as e}from"./caller-DULg_ZYV.js";import{t}from"./onAbort-BgMfspK4.js";import{n}from"./promise-CSLpzwSR.js";import{n as r}from"./bus-Cmk4U1hQ.js";import{t as i}from"./signal-field-key-CWT-3OXC.js";const a=e=>(t,n)=>{let r=n?.signal,i=r?AbortSignal.any([e,r]):e;return fetch(t,{...n,signal:i})},o=e=>({[i]:e}),s=(n,r)=>{let i=(e,i,a)=>{let o=a?.signal;if(!o)return n(e,i,a);let s=n(e,r?(e,t)=>(c(),i(e,t)):i,a),c=t({signal:o},s);return()=>{c(),s()}};return(t,n,r)=>e(()=>i(t,n,r))},c=e=>typeof e==`object`&&!!e,l=(e,t)=>{let n=t[0];if(!c(n)||!c(e)&&typeof e!=`function`)return;let r=Reflect.get(n,`signal`);return r instanceof AbortSignal?r:void 0},u=(e,i,a,o)=>{let s=t({signal:e},e=>{for(let{target:t}of i)t.abort(e)});return n(o)?o.then(s,s):r(a)&&s(),o},d=()=>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=l(e,r);if(!(a||t))return i(e,...r);let o=t,s=n,c=[];t=a?c:void 0,n=a;let d;try{d=i(e,...r)}finally{t=o,n=s}return a?u(a,c,e,d):d};let a=e.on,o=e.once;return e.on=s(a,!1),e.once=s(o,!0),e};export{o as fromSignal,a as signalFetch,d as signalPlugin};
1
+ import{i as e}from"./caller-DULg_ZYV.js";import{t}from"./onAbort-BgMfspK4.js";import{n}from"./promise-CSLpzwSR.js";import{n as r}from"./bus-Cmk4U1hQ.js";import{t as i}from"./signal-field-key-CWT-3OXC.js";const a=(e,t,n)=>{let r=n?.signal,i=r&&e.signal?AbortSignal.any([e.signal,r]):r??e.signal;return fetch(t,{...n,signal:i})},o=e=>({[i]:e}),s=(n,r)=>{let i=(e,i,a)=>{let o=a?.signal;if(!o)return n(e,i,a);let s=n(e,r?(e,t)=>(c(),i(e,t)):i,a),c=t({signal:o},s);return()=>{c(),s()}};return(t,n,r)=>e(()=>i(t,n,r))},c=e=>typeof e==`object`&&!!e,l=(e,t)=>{let n=t[0];if(!c(n)||!c(e)&&typeof e!=`function`)return;let r=Reflect.get(n,`signal`);return r instanceof AbortSignal?r:void 0},u=(e,i,a,o)=>{let s=t({signal:e},e=>{for(let{target:t}of i)t.abort(e)});return n(o)?o.then(s,s):r(a)&&s(),o},d=()=>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=l(e,r);if(!(a||t))return i(e,...r);let o=t,s=n,c=[];t=a?c:void 0,n=a;let d;try{d=i(e,...r)}finally{t=o,n=s}return a?u(a,c,e,d):d};let a=e.on,o=e.once;return e.on=s(a,!1),e.once=s(o,!0),e};export{o as fromSignal,a as signalFetch,d as signalPlugin};
package/dist/svelte.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as StateOf } from "./state-of-C19H6BoV.js";
3
3
  import { Readable } from "svelte/store";
4
4
  //#region src/svelte/plugin.d.ts
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, v as Bus, y as BusOptions } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, v as Bus, y as BusOptions } from "./action-creator-CXIq6GWK.js";
2
2
  //#region src/testing/clock.d.ts
3
3
  /** What the runner needs from time: move it forward, and read it. */
4
4
  type Clock = {
package/dist/trace.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { B as Tracer, H as EventMap, J as TaskInfo, K as Phase, V as EffectName, X as TraceEvent, Y as Trace, Z as TraceLogger, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { B as Tracer, H as EventMap, J as TaskInfo, K as Phase, V as EffectName, X as TraceEvent, Y as Trace, Z as TraceLogger, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  //#region src/graph/serialize.d.ts
3
3
  type JsonValue = null | boolean | number | string | JsonValue[] | {
4
4
  [key: string]: JsonValue;
@@ -1,5 +1,5 @@
1
- import { H as EventMap, c as Scope, l as WithFields, p as Effects } from "./action-creator-DprJV-pN.js";
2
- import { t as BoundOf } from "./plugin-2d3uuW7o.js";
1
+ import { H as EventMap, c as Scope, l as WithFields, p as Effects } from "./action-creator-CXIq6GWK.js";
2
+ import { t as BoundOf } from "./plugin-B8La3K9m.js";
3
3
  //#region src/scope/types.d.ts
4
4
  /**
5
5
  * The slots `scopePlugin` fills for `modulePlugin`. They are not on `Bus`: a bus
package/dist/vue.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-DprJV-pN.js";
1
+ import { H as EventMap, T as EventKey, a as FreeActionCreator, t as ActionCreator, v as Bus } from "./action-creator-CXIq6GWK.js";
2
2
  import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
3
  import { t as StateOf } from "./state-of-C19H6BoV.js";
4
4
  import { ShallowRef, WatchSource, WatchStopHandle } from "vue";
@@ -1,4 +1,4 @@
1
- import { G as PayloadOf, H as EventMap, q as Task } from "./action-creator-DprJV-pN.js";
1
+ import { G as PayloadOf, H as EventMap, q as Task } from "./action-creator-CXIq6GWK.js";
2
2
  //#region src/policies/with-policy.d.ts
3
3
  /** What `withPolicy` returns: the task as a plain function that can be cancelled. */
4
4
  type PolicyFunction<P, R> = {
package/docs/README.md CHANGED
@@ -3,10 +3,11 @@
3
3
  nanoeffects moves business logic and side effects out of UI components into plain `async` functions. Policies (`latest`, `debounce`, `queue`) and effects (`retry`, `timeout`, `poll`) handle races, retries and cancellation.
4
4
 
5
5
  ```js
6
+ // search.js
6
7
  import { createBus } from "nanoeffects";
7
8
  import { latest } from "nanoeffects/policies";
8
9
 
9
- const bus = createBus(); // event bus
10
+ export const bus = createBus(); // event bus
10
11
 
11
12
  // The search logic lives outside the component; latest aborts the previous request
12
13
  bus.on(
@@ -22,7 +23,33 @@ bus.on("found", (items) => render(items));
22
23
  bus.emit("search", "nano");
23
24
  ```
24
25
 
25
- The same example with a UI framework: [React](react.md), [Vue](vue.md), [Svelte](svelte.md). Ready projects: [Start a project from a template](starter.md).
26
+ In a component, the same bus needs no extra code. This is React; the handler stays in `search.js`:
27
+
28
+ ```jsx
29
+ // SearchBox.jsx
30
+ import { useEffect, useState } from "react";
31
+ import { bus } from "./search";
32
+
33
+ export function SearchBox() {
34
+ const [items, setItems] = useState([]);
35
+
36
+ // on returns the unbind function; returning it removes the listener on unmount
37
+ useEffect(() => bus.on("found", setItems), []);
38
+
39
+ return (
40
+ <>
41
+ <input onChange={(event) => bus.emit("search", event.target.value)} />
42
+ <ul>
43
+ {items.map((item) => (
44
+ <li key={item.id}>{item.title}</li>
45
+ ))}
46
+ </ul>
47
+ </>
48
+ );
49
+ }
50
+ ```
51
+
52
+ The same example step by step: [React](react.md), [Vue](vue.md), [Svelte](svelte.md). Ready projects: [Start a project from a template](starter.md).
26
53
 
27
54
  ## Install
28
55
 
@@ -47,7 +47,7 @@ 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)).
50
+ To make the mistake impossible, bind the signal once: `signalFetch(ctx, url)` is a `fetch` that stops with the handler, and `effectsPlugin({ fetch: signalFetch })` gives every handler a `ctx.fetch` of it ([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
51
 
52
52
  Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
53
53
 
package/docs/context.md CHANGED
@@ -99,7 +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
+ - For `fetch` you need no `fromSignal`: `signalFetch(ctx, url)` from the same entry stops with the task, and `effectsPlugin({ fetch: signalFetch })` gives every handler a `ctx.fetch`. [Bind the signal once](pass-the-signal.md#bind-the-signal-once)
103
103
  - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
104
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.
105
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.
package/docs/package.md CHANGED
@@ -9,7 +9,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
9
9
  | Import | What is in it |
10
10
  | ------ | ------------- |
11
11
  | `nanoeffects` | `createBus`, the types |
12
- | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
12
+ | `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue`, `dedupe`, `distinct`, `rateLimit`, `batch` |
13
13
  | `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
14
14
 
15
15
  ## Add the rest when you need it
@@ -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`; `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 |
24
+ | `nanoeffects/signal` | `signalPlugin`: `emit(call, { signal })` ties the tasks of an emit to an outside `AbortSignal`; `signalFetch(ctx, url)`: a `fetch` that stops with its task; `fromSignal`: a client 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 |
@@ -22,25 +22,36 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
22
22
 
23
23
  ## Bind the signal once
24
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.
25
+ The surest fix is a handler that never writes a signal. `signalFetch` is a `fetch` that takes `ctx` first, like `retry(ctx, ...)`: the request carries `ctx.signal`, so there is nothing to forget.
26
26
 
27
27
  ```js
28
- // bus.js
29
- import { createBus } from "nanoeffects";
30
- import { contextPlugin } from "nanoeffects/context";
31
- import { fromSignal, signalFetch } from "nanoeffects/signal";
32
- import axios from "axios";
28
+ // search-handlers.js
29
+ import { latest } from "nanoeffects/policies";
30
+ import { signalFetch } from "nanoeffects/signal";
31
+ import { bus } from "./bus";
33
32
 
34
- // fromSignal — ctx.fetch 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 })),
33
+ bus.on(
34
+ "search",
35
+ latest(async (query, ctx) => {
36
+ // signalFetch — fetch with ctx.signal: it stops when the policy cancels the handler
37
+ const response = await signalFetch(ctx, `/api?q=${query}`);
38
+ ctx.emit("results", await response.json());
40
39
  }),
41
40
  );
42
41
  ```
43
42
 
43
+ To get `ctx.fetch` in every handler, put the effect on the context once. `effectsPlugin` binds it to each task, so the handler drops the `ctx` argument:
44
+
45
+ ```js
46
+ // bus.js
47
+ import { createBus } from "nanoeffects";
48
+ import { effectsPlugin } from "nanoeffects/effects";
49
+ import { signalFetch } from "nanoeffects/signal";
50
+
51
+ // effectsPlugin — ctx.fetch is signalFetch bound to the task that reads it
52
+ export const bus = createBus().use(effectsPlugin({ fetch: signalFetch }));
53
+ ```
54
+
44
55
  ```js
45
56
  // search-handlers.js
46
57
  import { latest } from "nanoeffects/policies";
@@ -48,10 +59,27 @@ import { bus } from "./bus";
48
59
 
49
60
  bus.on(
50
61
  "search",
51
- latest(async (query, ctx) => {
52
- // 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());
62
+ latest(async (query, { fetch, emit }) => {
63
+ // no signal and no ctx argument: fetch stops when the policy cancels the handler
64
+ const response = await fetch(`/api?q=${query}`);
65
+ emit("results", await response.json());
66
+ }),
67
+ );
68
+ ```
69
+
70
+ A client that is not an effect, such as an `axios` instance, is made from the signal of each task with `fromSignal`:
71
+
72
+ ```js
73
+ // bus.js
74
+ import { createBus } from "nanoeffects";
75
+ import { contextPlugin } from "nanoeffects/context";
76
+ import { fromSignal } from "nanoeffects/signal";
77
+ import axios from "axios";
78
+
79
+ // fromSignal — ctx.http is made from the signal of the task that reads it
80
+ export const bus = createBus().use(
81
+ contextPlugin({
82
+ http: fromSignal((signal) => axios.create({ signal })),
55
83
  }),
56
84
  );
57
85
  ```
@@ -123,7 +151,7 @@ Each row is a situation where the signal is easy to lose. The right column says
123
151
  | 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 | |
124
152
  | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
125
153
  | `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 |
154
+ | A request that keeps running after the cancel, on Node | Pass the signal, or use `signalFetch(ctx, url)` | | yes |
127
155
  | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
128
156
  | `ctx.on` after the cancel | The same | | yes |
129
157
 
package/docs/policies.md CHANGED
@@ -6,6 +6,30 @@ Use this page when handlers overlap in a SPA (search-as-you-type, double submit,
6
6
 
7
7
  Start with `latest`. The table and sections below are the rest of the reference.
8
8
 
9
+ ## On this page
10
+
11
+ - [`latest`: abort the previous run](#latest-abort-the-previous-run)
12
+ - [Choose a policy](#choose-a-policy)
13
+ - [Look up a policy](#look-up-a-policy)
14
+ - [`latest`](#latest)
15
+ - [`leading`](#leading)
16
+ - [`debounce`](#debounce)
17
+ - [`throttle`](#throttle)
18
+ - [`queue`](#queue)
19
+ - [`dedupe`](#dedupe)
20
+ - [`distinct`](#distinct)
21
+ - [`rateLimit`](#ratelimit)
22
+ - [`batch`](#batch)
23
+ - [`key`](#key)
24
+ - [Configure debounce and throttle](#configure-debounce-and-throttle)
25
+ - [`key`: isolate state by key](#key-isolate-state-by-key)
26
+ - [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue)
27
+ - [`dedupe`: share one run between equal calls](#dedupe-share-one-run-between-equal-calls)
28
+ - [`distinct`: skip a repeated payload](#distinct-skip-a-repeated-payload)
29
+ - [`rateLimit`: stay under a quota](#ratelimit-stay-under-a-quota)
30
+ - [`batch`: send many calls as one](#batch-send-many-calls-as-one)
31
+ - [`withPolicy`: call a policy outside a bus](#withpolicy-call-a-policy-outside-a-bus)
32
+
9
33
  ## `latest`: abort the previous run
10
34
 
11
35
  ```js
@@ -24,7 +48,7 @@ bus.on(
24
48
  );
25
49
  ```
26
50
 
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)).
51
+ Pass `signal` to `fetch` (and the like) so the request cancels. To not pass it at all, bind it once: with `signalFetch(ctx, url)` 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
52
 
29
53
  ## Choose a policy
30
54
 
@@ -37,6 +61,10 @@ Every policy accepts `key` in its options (see [`key`: isolate state by key](#ke
37
61
  | `debounce(ms, fn, options?)` | run after `ms` of quiet |
38
62
  | `throttle(ms, fn, options?)` | at most one run per `ms` |
39
63
  | `queue(fn, options?)` | one after another, in order |
64
+ | `dedupe(fn, options?)` | calls with the same key share the run that is going |
65
+ | `distinct(fn, options?)` | a call with the same payload as the last one is skipped |
66
+ | `rateLimit(limit, ms, fn, options?)` | at most `limit` runs start per `ms`, the rest wait |
67
+ | `batch(ms, fn, options?)` | payloads of one window go to `fn` as one array |
40
68
 
41
69
  Each policy has a short entry in [Look up a policy](#look-up-a-policy).
42
70
 
@@ -84,6 +112,38 @@ Signature: `queue(fn, options?)`.
84
112
 
85
113
  More: [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue).
86
114
 
115
+ ### `dedupe`
116
+
117
+ Calls with the same key that come while a run is going wait for that run and get its result. Nothing is cached.
118
+
119
+ Signature: `dedupe(fn, options?)`.
120
+
121
+ More: [`dedupe`: share one run between equal calls](#dedupe-share-one-run-between-equal-calls).
122
+
123
+ ### `distinct`
124
+
125
+ A call whose payload equals the last one is skipped and resolves with `undefined`.
126
+
127
+ Signature: `distinct(fn, options?)`.
128
+
129
+ More: [`distinct`: skip a repeated payload](#distinct-skip-a-repeated-payload).
130
+
131
+ ### `rateLimit`
132
+
133
+ At most `limit` runs start in any window of `ms`. The other calls wait for their turn.
134
+
135
+ Signature: `rateLimit(limit, ms, fn, options?)`.
136
+
137
+ More: [`rateLimit`: stay under a quota](#ratelimit-stay-under-a-quota).
138
+
139
+ ### `batch`
140
+
141
+ Collects the payloads that come in a window of `ms` and calls `fn` once with all of them.
142
+
143
+ Signature: `batch(ms, fn, options?)`.
144
+
145
+ More: [`batch`: send many calls as one](#batch-send-many-calls-as-one).
146
+
87
147
  ### `key`
88
148
 
89
149
  An option of every policy. It splits the state of the policy by a property, for example by id: calls with different keys do not affect each other.
@@ -169,6 +229,134 @@ Every call returns a promise with the result of its own run. An error rejects on
169
229
  - When a running call aborts, its slot is freed at once and its result is dropped.
170
230
  - Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
171
231
 
232
+ ## `dedupe`: share one run between equal calls
233
+
234
+ > **Level B or C.** Use it for reads that several parts of the screen start at the same time.
235
+
236
+ **Problem.** Three components ask for user `1` in the same tick. Without a policy that is three identical requests.
237
+
238
+ **When.** A read that is safe to repeat: a profile, a config, a list by id. For a write use [`leading`](prevent-double-submit.md) or [`queue`](#run-calls-one-after-another-with-queue): see the abort rule below.
239
+
240
+ `dedupe` starts `fn` for the first call. A call with the same key that comes while that run is going waits for it and gets its result, or its error. When the run ends, `dedupe` forgets it, and the next call starts `fn` again. It is not a cache: it never returns an old value.
241
+
242
+ ```js
243
+ import { dedupe, withPolicy } from "nanoeffects/policies";
244
+
245
+ // dedupe — callers with the same id share one request
246
+ const policy = dedupe((id, { signal }) => api.getUser(id, { signal }));
247
+
248
+ // withPolicy — a plain function, so the example shows the result of each call
249
+ const loadUser = withPolicy(policy);
250
+
251
+ // two callers, one request: both get the same user
252
+ const [first, second] = await Promise.all([loadUser(1), loadUser(1)]);
253
+ ```
254
+
255
+ On a bus it looks the same, `bus.on("user/load", dedupe(handler))`. To get the result of `emit` use an action or a job ([Actions](actions.md)).
256
+
257
+ | Option | Meaning |
258
+ | ------ | ------- |
259
+ | `key` | what makes two calls "the same". Default: the payload itself, compared like a `Map` key. Objects never match unless `key` says how. `key: () => "all"` shares one run between every call |
260
+
261
+ **Abort:**
262
+
263
+ - A caller whose task aborts resolves with `undefined`. The others go on.
264
+ - The run belongs to the task of the first caller. If that task aborts while others still wait, the run is aborted and started again with the next caller, so `fn` can run twice for one request. That is fine for a read and wrong for a write.
265
+ - Pass `signal` into what `fn` does, as for any policy.
266
+
267
+ ## `distinct`: skip a repeated payload
268
+
269
+ > **Level B.** Use it when the same value keeps arriving and repeating the work is a waste.
270
+
271
+ **Problem.** A form saves on every change. A user types a letter and deletes it, and the same text goes to the server again.
272
+
273
+ `distinct` compares the payload of each call with the payload of the last call it let through. An equal payload is skipped: `fn` does not run and the call resolves with `undefined`. It remembers one payload, never a result.
274
+
275
+ ```js
276
+ import { distinct, withPolicy } from "nanoeffects/policies";
277
+
278
+ // distinct — runs only when the text differs from the last one it let through
279
+ const policy = distinct(async (text) => {
280
+ await api.save(text);
281
+ });
282
+ const save = withPolicy(policy);
283
+
284
+ await save("hello"); // runs
285
+ await save("hello"); // skipped, resolves undefined
286
+ await save("hello!"); // runs: the text changed
287
+ ```
288
+
289
+ | Option | Meaning |
290
+ | ------ | ------- |
291
+ | `equals(previous, next)` | tells whether two payloads are the same. Default: `Object.is` |
292
+ | `key` | each key remembers its own payload |
293
+
294
+ - The payload counts as seen from the moment its run starts, so the same payload that arrives while the run goes is skipped too.
295
+ - It is forgotten when the run fails or its task aborts, so a retry with the same payload runs.
296
+ - The memory of a key stays after the run. Unlike other policies, `distinct` does not drop idle keys, so give `key` values from a bounded set (a form id, not a request id).
297
+
298
+ ## `rateLimit`: stay under a quota
299
+
300
+ > **Level C.** Use it when an API allows N requests per period.
301
+
302
+ **Problem.** A geocoding API allows 5 requests per second. A list of 40 addresses sends 40 requests at once, and most of them fail with `429`.
303
+
304
+ `rateLimit(limit, ms, fn)` lets at most `limit` runs start in any window of `ms` milliseconds. The window slides: it is counted back from each call. A call over the limit waits and starts, in order, when the oldest start leaves the window.
305
+
306
+ ```js
307
+ import { rateLimit, withPolicy } from "nanoeffects/policies";
308
+
309
+ // rateLimit — 5 runs may start per second, the rest wait their turn
310
+ const policy = rateLimit(5, 1000, (address, { signal }) => {
311
+ return api.geocode(address, { signal });
312
+ });
313
+ const geocode = withPolicy(policy);
314
+
315
+ const addresses = ["10 Main St", "22 Oak Ave", "5 Pine Rd", "8 Elm St", "3 Lake Dr", "9 Hill Ln"];
316
+
317
+ // the first five start at once, the sixth a second later
318
+ const results = await Promise.all(addresses.map(geocode));
319
+ ```
320
+
321
+ | Option | Meaning |
322
+ | ------ | ------- |
323
+ | `overflow` | `"wait"` (default) keeps a call until it can start. `"drop"` drops a call that finds the window full: it resolves with `undefined` |
324
+ | `key` | each key has its own window, for example one per user |
325
+
326
+ - It limits **starts**, not how many runs go at the same time, and it does not wait for a run to end. For both limits write `queue(rateLimit(5, 1000, fn), { concurrency: 2 })`: a call keeps its slot in the queue while it waits for the window.
327
+ - A call whose task aborts while it waits leaves the line. Aborting a run that has started does not give its place in the window back: the request was sent.
328
+ - A `limit` below 1 is 1.
329
+
330
+ ## `batch`: send many calls as one
331
+
332
+ > **Level C.** Use it when work is cheaper in bulk: events for analytics, log lines, "get these ids".
333
+
334
+ **Problem.** A page sends an analytics event for every click, 30 requests a minute. The server prefers one request with all of them.
335
+
336
+ `batch(ms, fn)` opens a window of `ms` at the first call and collects the payloads that come in it. When the window closes, `fn` runs once with an array of them, in the order of the calls. The window does not move when more calls come, so a steady stream is sent every `ms`, not only when it stops.
337
+
338
+ ```js
339
+ import { batch, withPolicy } from "nanoeffects/policies";
340
+
341
+ // batch — fn gets an array of everything that came within 500 ms
342
+ const policy = batch(500, async (events) => {
343
+ await api.sendEvents(events);
344
+ });
345
+ const track = withPolicy(policy);
346
+
347
+ track({ name: "open" });
348
+ track({ name: "click" }); // one request with both events, 500 ms after the first
349
+ ```
350
+
351
+ | Option | Meaning |
352
+ | ------ | ------- |
353
+ | `maxSize` | send the batch at once when it holds this many payloads |
354
+ | `key` | each key collects its own batch, for example one per user |
355
+
356
+ - Every call resolves with what `fn` returned for its batch: the same value for all, or the same error. `batch` does not split an array result between callers.
357
+ - A call whose task aborts while the batch is collecting leaves it and resolves with `undefined`. When every call has left, nothing runs.
358
+ - `fn` gets the context of the first caller that is still waiting. If that task aborts while `fn` runs, the run is aborted and every caller of that batch resolves with `undefined`. The run is not repeated: part of a bulk call may have happened.
359
+
172
360
  ## `withPolicy`: call a policy outside a bus
173
361
 
174
362
  **Problem.** You want a debounced input handler or a "latest request wins" fetch for your own function, but the code has no bus: a DOM handler, a click callback, a function in a service. A policy needs a context on every call, so `debounce(300, search)("re")` does not work on its own.
package/docs/react.md CHANGED
@@ -8,6 +8,47 @@ Steps 1 and 2 are required, step 3 is not. The last two sections are extra.
8
8
 
9
9
  You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
10
10
 
11
+ The shortest version: a name field that asks the backend whether the name is free. The rest of the page builds a whole form from it.
12
+
13
+ ```js
14
+ // username.js
15
+ import { createBus } from "nanoeffects";
16
+ import { latest } from "nanoeffects/policies";
17
+
18
+ export const bus = createBus(); // the bus, created once
19
+
20
+ // the name check, outside the component; latest aborts the check of the previous name
21
+ bus.on(
22
+ "username/typed",
23
+ latest(async (name, ctx) => {
24
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
25
+ ctx.emit("username/checked", available);
26
+ }),
27
+ );
28
+ ```
29
+
30
+ ```jsx
31
+ // NameField.jsx
32
+ import { useEffect, useState } from "react";
33
+ import { bus } from "./username";
34
+
35
+ export function NameField() {
36
+ const [available, setAvailable] = useState(null); // null: not checked yet
37
+
38
+ // on returns the unbind function; returning it removes the listener on unmount
39
+ useEffect(() => bus.on("username/checked", setAvailable), []);
40
+
41
+ return (
42
+ <>
43
+ <input onChange={(event) => bus.emit("username/typed", event.target.value)} />
44
+ {available === false && <p>This name is taken</p>}
45
+ </>
46
+ );
47
+ }
48
+ ```
49
+
50
+ The handler knows nothing about React, and the component knows nothing about requests.
51
+
11
52
  ## On this page
12
53
 
13
54
  - [Problems of the form without a bus](#problems-of-the-form-without-a-bus)