nanoeffects 0.33.0 → 0.35.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/llms-full.txt CHANGED
@@ -394,7 +394,7 @@ Use strings for the plain case. Use objects when other code already builds actio
394
394
 
395
395
  **Problem.** The handler of an action is in the file of the feature, but a test needs a fake, or another layer owns the work. If the handler sits in `createAction`, the event file is tied to the work.
396
396
 
397
- **What to do.** Create the action without a handler: it is an event. Bind the real handler with `setHandler` when the bus is made. Later, `actions.handle` binds a newer handler that wins until you remove it.
397
+ **What to do.** Create the action without a handler: it is an event. Bind the real handler with `actions.handle` when the bus is made. A later `actions.handle` binds a newer handler that wins until you remove it.
398
398
 
399
399
  ```js
400
400
  // user.js
@@ -406,16 +406,16 @@ export const getUser = createAction("user/get"); // no handler yet: an event
406
406
  ```js
407
407
  // bus.js
408
408
  import { createBus } from "nanoeffects";
409
- import { actionsPlugin, setHandler } from "nanoeffects/actions";
409
+ import { actionsPlugin } from "nanoeffects/actions";
410
410
  import { getUser } from "./user";
411
411
 
412
412
  export const bus = createBus();
413
413
 
414
- // setHandler — binds the real handler to the action
415
- const realUser = setHandler(getUser, (id) => api.user(id));
416
-
417
414
  // actionsPlugin — the registry of bound handlers of this bus
418
- export const actions = bus.use(actionsPlugin([realUser]));
415
+ export const actions = bus.use(actionsPlugin());
416
+
417
+ // actions.handle — binds the real handler to the action
418
+ actions.handle(getUser, (id) => api.user(id));
419
419
  ```
420
420
 
421
421
  ```js
@@ -1312,7 +1312,7 @@ Each row is optional. The "when" column says what need brings you to it.
1312
1312
  | `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
1313
1313
  | `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
1314
1314
  | `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 |
1315
- | `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 |
1315
+ | `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 |
1316
1316
  | `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 |
1317
1317
  | `nanoeffects/scope` | `scopePlugin`, `effect`: fields for one action, job, module or `emit` (the types `Scope`, `Effect`) | one action or module needs its own fields |
1318
1318
  | `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
@@ -1772,7 +1772,7 @@ bus.on(
1772
1772
  );
1773
1773
  ```
1774
1774
 
1775
- 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)).
1775
+ 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)).
1776
1776
 
1777
1777
  ## Choose a policy
1778
1778
 
@@ -2002,7 +2002,7 @@ bus.on("results", (items) => render(items));
2002
2002
 
2003
2003
  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.
2004
2004
 
2005
- 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)).
2005
+ 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)).
2006
2006
 
2007
2007
  Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
2008
2008
 
@@ -3049,15 +3049,21 @@ export const sendMail = createAction("mail/send");
3049
3049
  ```js
3050
3050
  // bus.js
3051
3051
  import { createBus } from "nanoeffects";
3052
- import { actionsPlugin, setHandler } from "nanoeffects/actions";
3053
- import { sendMail } from "./mail";
3052
+ import { actionsPlugin } from "nanoeffects/actions";
3054
3053
 
3055
3054
  export const bus = createBus();
3056
3055
 
3057
- // actionsPlugin — the registry of handlers of this bus; setHandler pairs an action with its work
3058
- export const actions = bus.use(
3059
- actionsPlugin([setHandler(sendMail, smtpSend)]),
3060
- );
3056
+ // actionsPlugin — the registry of handlers of this bus
3057
+ export const actions = bus.use(actionsPlugin());
3058
+ ```
3059
+
3060
+ ```js
3061
+ // mail-handlers.js
3062
+ import { actions } from "./bus";
3063
+ import { sendMail } from "./mail";
3064
+
3065
+ // actions.handle — binds the work to the action on this bus
3066
+ actions.handle(sendMail, smtpSend);
3061
3067
  ```
3062
3068
 
3063
3069
  ```js
@@ -3071,7 +3077,7 @@ unbind();
3071
3077
 
3072
3078
  A handler bound in the registry makes `bus.emit(sendMail(mail))` run it and return its result, the same as for an action that carries its handler. The listeners of the type are told first. With no handler bound, `sendMail` is an event again.
3073
3079
 
3074
- The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the handler that the action carries. `bus.use(actionsPlugin())` sets `bus.actions`. A second `actionsPlugin` binds its list in that registry and returns it. There is no `actions` option in `createBus`; assign a custom runner with `bus.actions = { run }`, and add `bound(type)` to it to answer for actions that have no handler of their own.
3080
+ The registry lives in `nanoeffects/actions`, so a bus without one carries none of that code. Without a registry, `emit` runs the handler that the action carries. `bus.use(actionsPlugin())` sets `bus.actions`. A second `actionsPlugin` returns that registry. There is no `actions` option in `createBus`; assign a custom runner with `bus.actions = { run }`, and add `bound(type)` to it to answer for actions that have no handler of their own.
3075
3081
 
3076
3082
  ### The result type of a bound handler
3077
3083
 
@@ -4617,6 +4623,47 @@ Steps 1 and 2 are required, step 3 is not. The last two sections are extra.
4617
4623
 
4618
4624
  You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
4619
4625
 
4626
+ 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.
4627
+
4628
+ ```js
4629
+ // username.js
4630
+ import { createBus } from "nanoeffects";
4631
+ import { latest } from "nanoeffects/policies";
4632
+
4633
+ export const bus = createBus(); // the bus, created once
4634
+
4635
+ // the name check, outside the component; latest aborts the check of the previous name
4636
+ bus.on(
4637
+ "username/typed",
4638
+ latest(async (name, ctx) => {
4639
+ const available = await api.usernameAvailable(name, { signal: ctx.signal });
4640
+ ctx.emit("username/checked", available);
4641
+ }),
4642
+ );
4643
+ ```
4644
+
4645
+ ```jsx
4646
+ // NameField.jsx
4647
+ import { useEffect, useState } from "react";
4648
+ import { bus } from "./username";
4649
+
4650
+ export function NameField() {
4651
+ const [available, setAvailable] = useState(null); // null: not checked yet
4652
+
4653
+ // on returns the unbind function; returning it removes the listener on unmount
4654
+ useEffect(() => bus.on("username/checked", setAvailable), []);
4655
+
4656
+ return (
4657
+ <>
4658
+ <input onChange={(event) => bus.emit("username/typed", event.target.value)} />
4659
+ {available === false && <p>This name is taken</p>}
4660
+ </>
4661
+ );
4662
+ }
4663
+ ```
4664
+
4665
+ The handler knows nothing about React, and the component knows nothing about requests.
4666
+
4620
4667
  ## On this page
4621
4668
 
4622
4669
  - [Problems of the form without a bus](#problems-of-the-form-without-a-bus)
@@ -5412,6 +5459,51 @@ Use this page when your app is in Vue 3 and a component has to talk to the bus:
5412
5459
 
5413
5460
  You need Vue 3.3 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
5414
5461
 
5462
+ The shortest version: a search box that shows what the handler found. No plugin is needed for this; the sections below add a helper that shortens the component.
5463
+
5464
+ ```js
5465
+ // search.js
5466
+ import { createBus } from "nanoeffects";
5467
+ import { latest } from "nanoeffects/policies";
5468
+
5469
+ export const bus = createBus(); // the bus, created once
5470
+
5471
+ // the search logic, outside the component; latest aborts the previous request
5472
+ bus.on(
5473
+ "search",
5474
+ latest(async (query, ctx) => {
5475
+ const items = await api.search(query, { signal: ctx.signal });
5476
+ ctx.emit("found", items);
5477
+ }),
5478
+ );
5479
+ ```
5480
+
5481
+ The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
5482
+
5483
+ <!-- docs-check: skip - a .vue file -->
5484
+ ```vue
5485
+ <script setup>
5486
+ import { onUnmounted, ref } from "vue";
5487
+ import { bus } from "./search";
5488
+
5489
+ const items = ref([]);
5490
+
5491
+ // on returns the unbind function; unmounting calls it
5492
+ const unbind = bus.on("found", (found) => {
5493
+ items.value = found;
5494
+ });
5495
+ onUnmounted(unbind);
5496
+ </script>
5497
+
5498
+ <template>
5499
+ <input @input="bus.emit('search', $event.target.value)" />
5500
+ <ul>
5501
+ <li v-for="item in items" :key="item.id">{{ item.title }}</li>
5502
+ </ul>
5503
+ </template>
5504
+ ```
5505
+
5506
+
5415
5507
  ## Add the plugin to the bus
5416
5508
 
5417
5509
  **Problem.** Without help, a component writes `watch(query, (value) => bus.emit(queryChanged(value)))` and its own subscription in `onMounted` and `onUnmounted`. The same few lines are copied into every component, and each copy forgets something: the cleanup, the equality check, or the name of the subscription in the trace.
@@ -5514,6 +5606,47 @@ Use this page when your app is in Svelte 5 and a component has to show the resul
5514
5606
 
5515
5607
  You need Svelte 5 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
5516
5608
 
5609
+ The shortest version: a search box that shows what the handler found. No plugin is needed for this; the sections below add a helper that shortens the component.
5610
+
5611
+ ```js
5612
+ // search.js
5613
+ import { createBus } from "nanoeffects";
5614
+ import { latest } from "nanoeffects/policies";
5615
+
5616
+ export const bus = createBus(); // the bus, created once
5617
+
5618
+ // the search logic, outside the component; latest aborts the previous request
5619
+ bus.on(
5620
+ "search",
5621
+ latest(async (query, ctx) => {
5622
+ const items = await api.search(query, { signal: ctx.signal });
5623
+ ctx.emit("found", items);
5624
+ }),
5625
+ );
5626
+ ```
5627
+
5628
+ The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
5629
+
5630
+ <!-- docs-check: skip - a .svelte file -->
5631
+ ```svelte
5632
+ <script>
5633
+ import { bus } from "./search";
5634
+
5635
+ let items = $state([]);
5636
+
5637
+ // on returns the unbind function; $effect calls it on cleanup
5638
+ $effect(() => bus.on("found", (found) => (items = found)));
5639
+ </script>
5640
+
5641
+ <input oninput={(event) => bus.emit("search", event.target.value)} />
5642
+ <ul>
5643
+ {#each items as item (item.id)}
5644
+ <li>{item.title}</li>
5645
+ {/each}
5646
+ </ul>
5647
+ ```
5648
+
5649
+
5517
5650
  ## Add the plugin to the bus
5518
5651
 
5519
5652
  **Problem.** A Svelte component reads a value with `$store`. An event of the bus is not a store, so without help the component writes its own `readable` and its own `bus.on`, and has to remember to stop listening. The same few lines are copied into every component.
@@ -5694,7 +5827,7 @@ bus.on("user/open", async (id, ctx) => {
5694
5827
  });
5695
5828
  ```
5696
5829
 
5697
- - `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)
5830
+ - 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)
5698
5831
  - `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
5699
5832
  - A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
5700
5833
  - The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
@@ -7407,25 +7540,36 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
7407
7540
 
7408
7541
  ## Bind the signal once
7409
7542
 
7410
- 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.
7543
+ 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.
7411
7544
 
7412
7545
  ```js
7413
- // bus.js
7414
- import { createBus } from "nanoeffects";
7415
- import { contextPlugin } from "nanoeffects/context";
7416
- import { fromSignal, signalFetch } from "nanoeffects/signal";
7417
- import axios from "axios";
7546
+ // search-handlers.js
7547
+ import { latest } from "nanoeffects/policies";
7548
+ import { signalFetch } from "nanoeffects/signal";
7549
+ import { bus } from "./bus";
7418
7550
 
7419
- // fromSignal — ctx.fetch is made from the signal of the task that reads it
7420
- export const bus = createBus().use(
7421
- contextPlugin({
7422
- fetch: fromSignal(signalFetch),
7423
- // your own client too: it is made from the signal of each task
7424
- http: fromSignal((signal) => axios.create({ signal })),
7551
+ bus.on(
7552
+ "search",
7553
+ latest(async (query, ctx) => {
7554
+ // signalFetch — fetch with ctx.signal: it stops when the policy cancels the handler
7555
+ const response = await signalFetch(ctx, `/api?q=${query}`);
7556
+ ctx.emit("results", await response.json());
7425
7557
  }),
7426
7558
  );
7427
7559
  ```
7428
7560
 
7561
+ 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:
7562
+
7563
+ ```js
7564
+ // bus.js
7565
+ import { createBus } from "nanoeffects";
7566
+ import { effectsPlugin } from "nanoeffects/effects";
7567
+ import { signalFetch } from "nanoeffects/signal";
7568
+
7569
+ // effectsPlugin — ctx.fetch is signalFetch bound to the task that reads it
7570
+ export const bus = createBus().use(effectsPlugin({ fetch: signalFetch }));
7571
+ ```
7572
+
7429
7573
  ```js
7430
7574
  // search-handlers.js
7431
7575
  import { latest } from "nanoeffects/policies";
@@ -7433,10 +7577,27 @@ import { bus } from "./bus";
7433
7577
 
7434
7578
  bus.on(
7435
7579
  "search",
7436
- latest(async (query, ctx) => {
7437
- // no signal here: ctx.fetch stops when the policy cancels the handler
7438
- const response = await ctx.fetch(`/api?q=${query}`);
7439
- ctx.emit("results", await response.json());
7580
+ latest(async (query, { fetch, emit }) => {
7581
+ // no signal and no ctx argument: fetch stops when the policy cancels the handler
7582
+ const response = await fetch(`/api?q=${query}`);
7583
+ emit("results", await response.json());
7584
+ }),
7585
+ );
7586
+ ```
7587
+
7588
+ A client that is not an effect, such as an `axios` instance, is made from the signal of each task with `fromSignal`:
7589
+
7590
+ ```js
7591
+ // bus.js
7592
+ import { createBus } from "nanoeffects";
7593
+ import { contextPlugin } from "nanoeffects/context";
7594
+ import { fromSignal } from "nanoeffects/signal";
7595
+ import axios from "axios";
7596
+
7597
+ // fromSignal — ctx.http is made from the signal of the task that reads it
7598
+ export const bus = createBus().use(
7599
+ contextPlugin({
7600
+ http: fromSignal((signal) => axios.create({ signal })),
7440
7601
  }),
7441
7602
  );
7442
7603
  ```
@@ -7508,7 +7669,7 @@ Each row is a situation where the signal is easy to lose. The right column says
7508
7669
  | 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 | |
7509
7670
  | The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
7510
7671
  | `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
7511
- | A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx.fetch` | | yes |
7672
+ | A request that keeps running after the cancel, on Node | Pass the signal, or use `signalFetch(ctx, url)` | | yes |
7512
7673
  | Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
7513
7674
  | `ctx.on` after the cancel | The same | | yes |
7514
7675
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nanoeffects",
3
- "version": "0.33.0",
3
+ "version": "0.35.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",
@@ -246,7 +246,7 @@
246
246
  {
247
247
  "name": "signal (plugin)",
248
248
  "path": "dist/signal.js",
249
- "limit": "0.8 kB"
249
+ "limit": "0.81 kB"
250
250
  },
251
251
  {
252
252
  "name": "context (plugin)",