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.
- package/dist/{action-creator-DprJV-pN.d.ts → action-creator-CXIq6GWK.d.ts} +1 -1
- package/dist/actions.d.ts +3 -3
- package/dist/{context-types-BiTMp_lW.d.ts → context-types-BO1QE6W2.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +2 -2
- package/dist/guard.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/{module-Fk4OtkZk.d.ts → module-BXNaEXE9.d.ts} +2 -2
- package/dist/notrace/{action-creator-BWd9FWt6.d.ts → action-creator-JUwIsFUl.d.ts} +1 -1
- package/dist/notrace/actions.d.ts +2 -2
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +1 -1
- package/dist/notrace/{plugin-DmAYT3Xk.d.ts → plugin-dLdTM2JL.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +158 -2
- package/dist/notrace/policies.js +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/{plugin-2d3uuW7o.d.ts → plugin-B8La3K9m.d.ts} +2 -2
- package/dist/policies.d.ts +159 -3
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +2 -2
- package/dist/signal.d.ts +12 -11
- package/dist/signal.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/trace.d.ts +1 -1
- package/dist/{types-DxPZDzZG.d.ts → types-ByoeMqVh.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-Y12d-yx6.d.ts → with-policy-e4yx1vhN.d.ts} +1 -1
- package/docs/README.md +29 -2
- package/docs/cancel-stale-requests.md +1 -1
- package/docs/context.md +1 -1
- package/docs/package.md +2 -2
- package/docs/pass-the-signal.md +45 -17
- package/docs/policies.md +189 -1
- package/docs/react.md +41 -0
- package/docs/recipes.md +90 -0
- package/docs/ru/README.md +29 -2
- package/docs/ru/cancel-stale-requests.md +1 -1
- package/docs/ru/context.md +1 -1
- package/docs/ru/package.md +2 -2
- package/docs/ru/pass-the-signal.md +46 -18
- package/docs/ru/policies.md +189 -1
- package/docs/ru/react.md +41 -0
- package/docs/ru/recipes.md +90 -0
- package/docs/ru/svelte.md +41 -0
- package/docs/ru/vue.md +45 -0
- package/docs/svelte.md +41 -0
- package/docs/vue.md +45 -0
- package/llms-full.txt +455 -22
- package/llms.txt +1 -1
- package/package.json +3 -3
package/llms-full.txt
CHANGED
|
@@ -568,6 +568,8 @@ Each task below starts with the problem, then a short solution. Where the page h
|
|
|
568
568
|
- [A second click sends a second request](#a-second-click-sends-a-second-request)
|
|
569
569
|
- [Messages must go out in order](#messages-must-go-out-in-order)
|
|
570
570
|
- [A "Delete" button in a list blocks the other rows](#a-delete-button-in-a-list-blocks-the-other-rows)
|
|
571
|
+
- [Several components ask for the same data](#several-components-ask-for-the-same-data)
|
|
572
|
+
- [A form saves the same text twice](#a-form-saves-the-same-text-twice)
|
|
571
573
|
- [An unreliable network and long flows](#an-unreliable-network-and-long-flows)
|
|
572
574
|
- [A request fails sometimes: retry and backoff](#a-request-fails-sometimes-retry-and-backoff)
|
|
573
575
|
- [A request hangs: timeout](#a-request-hangs-timeout)
|
|
@@ -575,6 +577,8 @@ Each task below starts with the problem, then a short solution. Where the page h
|
|
|
575
577
|
- [Wait for another event inside a handler: take](#wait-for-another-event-inside-a-handler-take)
|
|
576
578
|
- [Load several things at once: all](#load-several-things-at-once-all)
|
|
577
579
|
- [Take the fastest answer: race](#take-the-fastest-answer-race)
|
|
580
|
+
- [An API allows only N requests per second](#an-api-allows-only-n-requests-per-second)
|
|
581
|
+
- [Many small events, one request](#many-small-events-one-request)
|
|
578
582
|
- [Features and domain logic](#features-and-domain-logic)
|
|
579
583
|
- [Get the result of emit](#get-the-result-of-emit)
|
|
580
584
|
- [Group a feature with its handlers: modules](#group-a-feature-with-its-handlers-modules)
|
|
@@ -790,6 +794,49 @@ bus.on(
|
|
|
790
794
|
|
|
791
795
|
More: [Policies, `key`](policies.md#key-isolate-state-by-key).
|
|
792
796
|
|
|
797
|
+
### Several components ask for the same data
|
|
798
|
+
|
|
799
|
+
A page shows a user in the header, in a sidebar and in a comment, and all three ask for user `1` in the same tick. That is three identical requests. `dedupe` makes the calls for one id share a single request. Use it for reads, not for writes.
|
|
800
|
+
|
|
801
|
+
```js
|
|
802
|
+
import { createBus } from "nanoeffects";
|
|
803
|
+
import { dedupe } from "nanoeffects/policies";
|
|
804
|
+
|
|
805
|
+
const bus = createBus();
|
|
806
|
+
|
|
807
|
+
// dedupe — a call for an id that is loading already waits for that request
|
|
808
|
+
bus.on(
|
|
809
|
+
"user/needed",
|
|
810
|
+
dedupe(async (id, ctx) => {
|
|
811
|
+
const user = await api.getUser(id, { signal: ctx.signal });
|
|
812
|
+
ctx.emit("user/loaded", user);
|
|
813
|
+
}),
|
|
814
|
+
);
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
More: [Policies, `dedupe`](policies.md#dedupe-share-one-run-between-equal-calls).
|
|
818
|
+
|
|
819
|
+
### A form saves the same text twice
|
|
820
|
+
|
|
821
|
+
A form saves on every change. A user types a letter and deletes it, and the same text goes to the server again. `distinct` skips a call whose payload equals the last one.
|
|
822
|
+
|
|
823
|
+
```js
|
|
824
|
+
import { createBus } from "nanoeffects";
|
|
825
|
+
import { distinct } from "nanoeffects/policies";
|
|
826
|
+
|
|
827
|
+
const bus = createBus();
|
|
828
|
+
|
|
829
|
+
// distinct — a text equal to the last saved one is skipped
|
|
830
|
+
bus.on(
|
|
831
|
+
"draft/changed",
|
|
832
|
+
distinct(async (text, { signal }) => {
|
|
833
|
+
await api.saveDraft(text, { signal });
|
|
834
|
+
}),
|
|
835
|
+
);
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
More: [Policies, `distinct`](policies.md#distinct-skip-a-repeated-payload).
|
|
839
|
+
|
|
793
840
|
## An unreliable network and long flows
|
|
794
841
|
|
|
795
842
|
### A request fails sometimes: retry and backoff
|
|
@@ -921,6 +968,49 @@ bus.on("user/opened", async (id, ctx) => {
|
|
|
921
968
|
|
|
922
969
|
More: [`all`](effects.md#all), [`race`](effects.md#race).
|
|
923
970
|
|
|
971
|
+
### An API allows only N requests per second
|
|
972
|
+
|
|
973
|
+
A geocoding API allows 5 requests per second. A list of 40 addresses sends 40 requests at once, and most fail with `429`. `rateLimit` starts five and holds the rest until the window slides.
|
|
974
|
+
|
|
975
|
+
```js
|
|
976
|
+
import { createBus } from "nanoeffects";
|
|
977
|
+
import { rateLimit } from "nanoeffects/policies";
|
|
978
|
+
|
|
979
|
+
const bus = createBus();
|
|
980
|
+
|
|
981
|
+
// rateLimit — five requests may start per second, the others wait their turn
|
|
982
|
+
bus.on(
|
|
983
|
+
"address/geocode",
|
|
984
|
+
rateLimit(5, 1000, async (address, ctx) => {
|
|
985
|
+
const place = await api.geocode(address, { signal: ctx.signal });
|
|
986
|
+
ctx.emit("address/found", place);
|
|
987
|
+
}),
|
|
988
|
+
);
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
More: [Policies, `rateLimit`](policies.md#ratelimit-stay-under-a-quota).
|
|
992
|
+
|
|
993
|
+
### Many small events, one request
|
|
994
|
+
|
|
995
|
+
A page sends an analytics event on every click, 30 requests a minute. The server prefers one request with all of them. `batch` collects the events that come within a window and sends them together.
|
|
996
|
+
|
|
997
|
+
```js
|
|
998
|
+
import { createBus } from "nanoeffects";
|
|
999
|
+
import { batch } from "nanoeffects/policies";
|
|
1000
|
+
|
|
1001
|
+
const bus = createBus();
|
|
1002
|
+
|
|
1003
|
+
// batch — everything that comes within 500 ms goes in one request
|
|
1004
|
+
bus.on(
|
|
1005
|
+
"analytics/event",
|
|
1006
|
+
batch(500, async (events, { signal }) => {
|
|
1007
|
+
await api.sendEvents(events, { signal });
|
|
1008
|
+
}),
|
|
1009
|
+
);
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
More: [Policies, `batch`](policies.md#batch-send-many-calls-as-one).
|
|
1013
|
+
|
|
924
1014
|
## Features and domain logic
|
|
925
1015
|
|
|
926
1016
|
### Get the result of emit
|
|
@@ -1300,7 +1390,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
|
|
|
1300
1390
|
| Import | What is in it |
|
|
1301
1391
|
| ------ | ------------- |
|
|
1302
1392
|
| `nanoeffects` | `createBus`, the types |
|
|
1303
|
-
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue` |
|
|
1393
|
+
| `nanoeffects/policies` | `latest`, `leading`, `debounce`, `throttle`, `queue`, `dedupe`, `distinct`, `rateLimit`, `batch` |
|
|
1304
1394
|
| `nanoeffects/effects` | `retry`, `timeout`, `poll`, `delay`, `take`, `all`, `allSettled`, `race`, `effectsPlugin` |
|
|
1305
1395
|
|
|
1306
1396
|
## Add the rest when you need it
|
|
@@ -1312,7 +1402,7 @@ Each row is optional. The "when" column says what need brings you to it.
|
|
|
1312
1402
|
| `nanoeffects/actions` | `createAction`, `createJob`, `createModule`, `modulePlugin`, `actionsPlugin` | `emit` must return a result, or domain logic leaves the UI |
|
|
1313
1403
|
| `nanoeffects/match` | `matchPlugin`: `on`, `once` and `off` that take a function which picks the events | `on` must take a function that picks events |
|
|
1314
1404
|
| `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`; `
|
|
1405
|
+
| `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
1406
|
| `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
1407
|
| `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
1408
|
| `nanoeffects/redux` | `createEffectsMiddleware` | you already use Redux |
|
|
@@ -1754,6 +1844,30 @@ Use this page when handlers overlap in a SPA (search-as-you-type, double submit,
|
|
|
1754
1844
|
|
|
1755
1845
|
Start with `latest`. The table and sections below are the rest of the reference.
|
|
1756
1846
|
|
|
1847
|
+
## On this page
|
|
1848
|
+
|
|
1849
|
+
- [`latest`: abort the previous run](#latest-abort-the-previous-run)
|
|
1850
|
+
- [Choose a policy](#choose-a-policy)
|
|
1851
|
+
- [Look up a policy](#look-up-a-policy)
|
|
1852
|
+
- [`latest`](#latest)
|
|
1853
|
+
- [`leading`](#leading)
|
|
1854
|
+
- [`debounce`](#debounce)
|
|
1855
|
+
- [`throttle`](#throttle)
|
|
1856
|
+
- [`queue`](#queue)
|
|
1857
|
+
- [`dedupe`](#dedupe)
|
|
1858
|
+
- [`distinct`](#distinct)
|
|
1859
|
+
- [`rateLimit`](#ratelimit)
|
|
1860
|
+
- [`batch`](#batch)
|
|
1861
|
+
- [`key`](#key)
|
|
1862
|
+
- [Configure debounce and throttle](#configure-debounce-and-throttle)
|
|
1863
|
+
- [`key`: isolate state by key](#key-isolate-state-by-key)
|
|
1864
|
+
- [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue)
|
|
1865
|
+
- [`dedupe`: share one run between equal calls](#dedupe-share-one-run-between-equal-calls)
|
|
1866
|
+
- [`distinct`: skip a repeated payload](#distinct-skip-a-repeated-payload)
|
|
1867
|
+
- [`rateLimit`: stay under a quota](#ratelimit-stay-under-a-quota)
|
|
1868
|
+
- [`batch`: send many calls as one](#batch-send-many-calls-as-one)
|
|
1869
|
+
- [`withPolicy`: call a policy outside a bus](#withpolicy-call-a-policy-outside-a-bus)
|
|
1870
|
+
|
|
1757
1871
|
## `latest`: abort the previous run
|
|
1758
1872
|
|
|
1759
1873
|
```js
|
|
@@ -1772,7 +1886,7 @@ bus.on(
|
|
|
1772
1886
|
);
|
|
1773
1887
|
```
|
|
1774
1888
|
|
|
1775
|
-
Pass `signal` to `fetch` (and the like) so the request cancels. To not pass it at all, bind it once: with `ctx
|
|
1889
|
+
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
1890
|
|
|
1777
1891
|
## Choose a policy
|
|
1778
1892
|
|
|
@@ -1785,6 +1899,10 @@ Every policy accepts `key` in its options (see [`key`: isolate state by key](#ke
|
|
|
1785
1899
|
| `debounce(ms, fn, options?)` | run after `ms` of quiet |
|
|
1786
1900
|
| `throttle(ms, fn, options?)` | at most one run per `ms` |
|
|
1787
1901
|
| `queue(fn, options?)` | one after another, in order |
|
|
1902
|
+
| `dedupe(fn, options?)` | calls with the same key share the run that is going |
|
|
1903
|
+
| `distinct(fn, options?)` | a call with the same payload as the last one is skipped |
|
|
1904
|
+
| `rateLimit(limit, ms, fn, options?)` | at most `limit` runs start per `ms`, the rest wait |
|
|
1905
|
+
| `batch(ms, fn, options?)` | payloads of one window go to `fn` as one array |
|
|
1788
1906
|
|
|
1789
1907
|
Each policy has a short entry in [Look up a policy](#look-up-a-policy).
|
|
1790
1908
|
|
|
@@ -1832,6 +1950,38 @@ Signature: `queue(fn, options?)`.
|
|
|
1832
1950
|
|
|
1833
1951
|
More: [Run calls one after another with `queue`](#run-calls-one-after-another-with-queue).
|
|
1834
1952
|
|
|
1953
|
+
### `dedupe`
|
|
1954
|
+
|
|
1955
|
+
Calls with the same key that come while a run is going wait for that run and get its result. Nothing is cached.
|
|
1956
|
+
|
|
1957
|
+
Signature: `dedupe(fn, options?)`.
|
|
1958
|
+
|
|
1959
|
+
More: [`dedupe`: share one run between equal calls](#dedupe-share-one-run-between-equal-calls).
|
|
1960
|
+
|
|
1961
|
+
### `distinct`
|
|
1962
|
+
|
|
1963
|
+
A call whose payload equals the last one is skipped and resolves with `undefined`.
|
|
1964
|
+
|
|
1965
|
+
Signature: `distinct(fn, options?)`.
|
|
1966
|
+
|
|
1967
|
+
More: [`distinct`: skip a repeated payload](#distinct-skip-a-repeated-payload).
|
|
1968
|
+
|
|
1969
|
+
### `rateLimit`
|
|
1970
|
+
|
|
1971
|
+
At most `limit` runs start in any window of `ms`. The other calls wait for their turn.
|
|
1972
|
+
|
|
1973
|
+
Signature: `rateLimit(limit, ms, fn, options?)`.
|
|
1974
|
+
|
|
1975
|
+
More: [`rateLimit`: stay under a quota](#ratelimit-stay-under-a-quota).
|
|
1976
|
+
|
|
1977
|
+
### `batch`
|
|
1978
|
+
|
|
1979
|
+
Collects the payloads that come in a window of `ms` and calls `fn` once with all of them.
|
|
1980
|
+
|
|
1981
|
+
Signature: `batch(ms, fn, options?)`.
|
|
1982
|
+
|
|
1983
|
+
More: [`batch`: send many calls as one](#batch-send-many-calls-as-one).
|
|
1984
|
+
|
|
1835
1985
|
### `key`
|
|
1836
1986
|
|
|
1837
1987
|
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.
|
|
@@ -1917,6 +2067,134 @@ Every call returns a promise with the result of its own run. An error rejects on
|
|
|
1917
2067
|
- When a running call aborts, its slot is freed at once and its result is dropped.
|
|
1918
2068
|
- Pass `signal` into what `fn` does. A `fn` that ignores it can overlap with the next run.
|
|
1919
2069
|
|
|
2070
|
+
## `dedupe`: share one run between equal calls
|
|
2071
|
+
|
|
2072
|
+
> **Level B or C.** Use it for reads that several parts of the screen start at the same time.
|
|
2073
|
+
|
|
2074
|
+
**Problem.** Three components ask for user `1` in the same tick. Without a policy that is three identical requests.
|
|
2075
|
+
|
|
2076
|
+
**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.
|
|
2077
|
+
|
|
2078
|
+
`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.
|
|
2079
|
+
|
|
2080
|
+
```js
|
|
2081
|
+
import { dedupe, withPolicy } from "nanoeffects/policies";
|
|
2082
|
+
|
|
2083
|
+
// dedupe — callers with the same id share one request
|
|
2084
|
+
const policy = dedupe((id, { signal }) => api.getUser(id, { signal }));
|
|
2085
|
+
|
|
2086
|
+
// withPolicy — a plain function, so the example shows the result of each call
|
|
2087
|
+
const loadUser = withPolicy(policy);
|
|
2088
|
+
|
|
2089
|
+
// two callers, one request: both get the same user
|
|
2090
|
+
const [first, second] = await Promise.all([loadUser(1), loadUser(1)]);
|
|
2091
|
+
```
|
|
2092
|
+
|
|
2093
|
+
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)).
|
|
2094
|
+
|
|
2095
|
+
| Option | Meaning |
|
|
2096
|
+
| ------ | ------- |
|
|
2097
|
+
| `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 |
|
|
2098
|
+
|
|
2099
|
+
**Abort:**
|
|
2100
|
+
|
|
2101
|
+
- A caller whose task aborts resolves with `undefined`. The others go on.
|
|
2102
|
+
- 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.
|
|
2103
|
+
- Pass `signal` into what `fn` does, as for any policy.
|
|
2104
|
+
|
|
2105
|
+
## `distinct`: skip a repeated payload
|
|
2106
|
+
|
|
2107
|
+
> **Level B.** Use it when the same value keeps arriving and repeating the work is a waste.
|
|
2108
|
+
|
|
2109
|
+
**Problem.** A form saves on every change. A user types a letter and deletes it, and the same text goes to the server again.
|
|
2110
|
+
|
|
2111
|
+
`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.
|
|
2112
|
+
|
|
2113
|
+
```js
|
|
2114
|
+
import { distinct, withPolicy } from "nanoeffects/policies";
|
|
2115
|
+
|
|
2116
|
+
// distinct — runs only when the text differs from the last one it let through
|
|
2117
|
+
const policy = distinct(async (text) => {
|
|
2118
|
+
await api.save(text);
|
|
2119
|
+
});
|
|
2120
|
+
const save = withPolicy(policy);
|
|
2121
|
+
|
|
2122
|
+
await save("hello"); // runs
|
|
2123
|
+
await save("hello"); // skipped, resolves undefined
|
|
2124
|
+
await save("hello!"); // runs: the text changed
|
|
2125
|
+
```
|
|
2126
|
+
|
|
2127
|
+
| Option | Meaning |
|
|
2128
|
+
| ------ | ------- |
|
|
2129
|
+
| `equals(previous, next)` | tells whether two payloads are the same. Default: `Object.is` |
|
|
2130
|
+
| `key` | each key remembers its own payload |
|
|
2131
|
+
|
|
2132
|
+
- The payload counts as seen from the moment its run starts, so the same payload that arrives while the run goes is skipped too.
|
|
2133
|
+
- It is forgotten when the run fails or its task aborts, so a retry with the same payload runs.
|
|
2134
|
+
- 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).
|
|
2135
|
+
|
|
2136
|
+
## `rateLimit`: stay under a quota
|
|
2137
|
+
|
|
2138
|
+
> **Level C.** Use it when an API allows N requests per period.
|
|
2139
|
+
|
|
2140
|
+
**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`.
|
|
2141
|
+
|
|
2142
|
+
`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.
|
|
2143
|
+
|
|
2144
|
+
```js
|
|
2145
|
+
import { rateLimit, withPolicy } from "nanoeffects/policies";
|
|
2146
|
+
|
|
2147
|
+
// rateLimit — 5 runs may start per second, the rest wait their turn
|
|
2148
|
+
const policy = rateLimit(5, 1000, (address, { signal }) => {
|
|
2149
|
+
return api.geocode(address, { signal });
|
|
2150
|
+
});
|
|
2151
|
+
const geocode = withPolicy(policy);
|
|
2152
|
+
|
|
2153
|
+
const addresses = ["10 Main St", "22 Oak Ave", "5 Pine Rd", "8 Elm St", "3 Lake Dr", "9 Hill Ln"];
|
|
2154
|
+
|
|
2155
|
+
// the first five start at once, the sixth a second later
|
|
2156
|
+
const results = await Promise.all(addresses.map(geocode));
|
|
2157
|
+
```
|
|
2158
|
+
|
|
2159
|
+
| Option | Meaning |
|
|
2160
|
+
| ------ | ------- |
|
|
2161
|
+
| `overflow` | `"wait"` (default) keeps a call until it can start. `"drop"` drops a call that finds the window full: it resolves with `undefined` |
|
|
2162
|
+
| `key` | each key has its own window, for example one per user |
|
|
2163
|
+
|
|
2164
|
+
- 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.
|
|
2165
|
+
- 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.
|
|
2166
|
+
- A `limit` below 1 is 1.
|
|
2167
|
+
|
|
2168
|
+
## `batch`: send many calls as one
|
|
2169
|
+
|
|
2170
|
+
> **Level C.** Use it when work is cheaper in bulk: events for analytics, log lines, "get these ids".
|
|
2171
|
+
|
|
2172
|
+
**Problem.** A page sends an analytics event for every click, 30 requests a minute. The server prefers one request with all of them.
|
|
2173
|
+
|
|
2174
|
+
`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.
|
|
2175
|
+
|
|
2176
|
+
```js
|
|
2177
|
+
import { batch, withPolicy } from "nanoeffects/policies";
|
|
2178
|
+
|
|
2179
|
+
// batch — fn gets an array of everything that came within 500 ms
|
|
2180
|
+
const policy = batch(500, async (events) => {
|
|
2181
|
+
await api.sendEvents(events);
|
|
2182
|
+
});
|
|
2183
|
+
const track = withPolicy(policy);
|
|
2184
|
+
|
|
2185
|
+
track({ name: "open" });
|
|
2186
|
+
track({ name: "click" }); // one request with both events, 500 ms after the first
|
|
2187
|
+
```
|
|
2188
|
+
|
|
2189
|
+
| Option | Meaning |
|
|
2190
|
+
| ------ | ------- |
|
|
2191
|
+
| `maxSize` | send the batch at once when it holds this many payloads |
|
|
2192
|
+
| `key` | each key collects its own batch, for example one per user |
|
|
2193
|
+
|
|
2194
|
+
- 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.
|
|
2195
|
+
- A call whose task aborts while the batch is collecting leaves it and resolves with `undefined`. When every call has left, nothing runs.
|
|
2196
|
+
- `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.
|
|
2197
|
+
|
|
1920
2198
|
## `withPolicy`: call a policy outside a bus
|
|
1921
2199
|
|
|
1922
2200
|
**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.
|
|
@@ -2002,7 +2280,7 @@ bus.on("results", (items) => render(items));
|
|
|
2002
2280
|
|
|
2003
2281
|
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
2282
|
|
|
2005
|
-
To make the mistake impossible, bind the signal once: `
|
|
2283
|
+
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
2284
|
|
|
2007
2285
|
Result: `emit("search", "ab")` followed by `emit("search", "abc")` shows only the results for `abc`, and the request for `ab` is cancelled.
|
|
2008
2286
|
|
|
@@ -4623,6 +4901,47 @@ Steps 1 and 2 are required, step 3 is not. The last two sections are extra.
|
|
|
4623
4901
|
|
|
4624
4902
|
You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
|
|
4625
4903
|
|
|
4904
|
+
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.
|
|
4905
|
+
|
|
4906
|
+
```js
|
|
4907
|
+
// username.js
|
|
4908
|
+
import { createBus } from "nanoeffects";
|
|
4909
|
+
import { latest } from "nanoeffects/policies";
|
|
4910
|
+
|
|
4911
|
+
export const bus = createBus(); // the bus, created once
|
|
4912
|
+
|
|
4913
|
+
// the name check, outside the component; latest aborts the check of the previous name
|
|
4914
|
+
bus.on(
|
|
4915
|
+
"username/typed",
|
|
4916
|
+
latest(async (name, ctx) => {
|
|
4917
|
+
const available = await api.usernameAvailable(name, { signal: ctx.signal });
|
|
4918
|
+
ctx.emit("username/checked", available);
|
|
4919
|
+
}),
|
|
4920
|
+
);
|
|
4921
|
+
```
|
|
4922
|
+
|
|
4923
|
+
```jsx
|
|
4924
|
+
// NameField.jsx
|
|
4925
|
+
import { useEffect, useState } from "react";
|
|
4926
|
+
import { bus } from "./username";
|
|
4927
|
+
|
|
4928
|
+
export function NameField() {
|
|
4929
|
+
const [available, setAvailable] = useState(null); // null: not checked yet
|
|
4930
|
+
|
|
4931
|
+
// on returns the unbind function; returning it removes the listener on unmount
|
|
4932
|
+
useEffect(() => bus.on("username/checked", setAvailable), []);
|
|
4933
|
+
|
|
4934
|
+
return (
|
|
4935
|
+
<>
|
|
4936
|
+
<input onChange={(event) => bus.emit("username/typed", event.target.value)} />
|
|
4937
|
+
{available === false && <p>This name is taken</p>}
|
|
4938
|
+
</>
|
|
4939
|
+
);
|
|
4940
|
+
}
|
|
4941
|
+
```
|
|
4942
|
+
|
|
4943
|
+
The handler knows nothing about React, and the component knows nothing about requests.
|
|
4944
|
+
|
|
4626
4945
|
## On this page
|
|
4627
4946
|
|
|
4628
4947
|
- [Problems of the form without a bus](#problems-of-the-form-without-a-bus)
|
|
@@ -5418,6 +5737,51 @@ Use this page when your app is in Vue 3 and a component has to talk to the bus:
|
|
|
5418
5737
|
|
|
5419
5738
|
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.
|
|
5420
5739
|
|
|
5740
|
+
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.
|
|
5741
|
+
|
|
5742
|
+
```js
|
|
5743
|
+
// search.js
|
|
5744
|
+
import { createBus } from "nanoeffects";
|
|
5745
|
+
import { latest } from "nanoeffects/policies";
|
|
5746
|
+
|
|
5747
|
+
export const bus = createBus(); // the bus, created once
|
|
5748
|
+
|
|
5749
|
+
// the search logic, outside the component; latest aborts the previous request
|
|
5750
|
+
bus.on(
|
|
5751
|
+
"search",
|
|
5752
|
+
latest(async (query, ctx) => {
|
|
5753
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
5754
|
+
ctx.emit("found", items);
|
|
5755
|
+
}),
|
|
5756
|
+
);
|
|
5757
|
+
```
|
|
5758
|
+
|
|
5759
|
+
The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
|
|
5760
|
+
|
|
5761
|
+
<!-- docs-check: skip - a .vue file -->
|
|
5762
|
+
```vue
|
|
5763
|
+
<script setup>
|
|
5764
|
+
import { onUnmounted, ref } from "vue";
|
|
5765
|
+
import { bus } from "./search";
|
|
5766
|
+
|
|
5767
|
+
const items = ref([]);
|
|
5768
|
+
|
|
5769
|
+
// on returns the unbind function; unmounting calls it
|
|
5770
|
+
const unbind = bus.on("found", (found) => {
|
|
5771
|
+
items.value = found;
|
|
5772
|
+
});
|
|
5773
|
+
onUnmounted(unbind);
|
|
5774
|
+
</script>
|
|
5775
|
+
|
|
5776
|
+
<template>
|
|
5777
|
+
<input @input="bus.emit('search', $event.target.value)" />
|
|
5778
|
+
<ul>
|
|
5779
|
+
<li v-for="item in items" :key="item.id">{{ item.title }}</li>
|
|
5780
|
+
</ul>
|
|
5781
|
+
</template>
|
|
5782
|
+
```
|
|
5783
|
+
|
|
5784
|
+
|
|
5421
5785
|
## Add the plugin to the bus
|
|
5422
5786
|
|
|
5423
5787
|
**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.
|
|
@@ -5520,6 +5884,47 @@ Use this page when your app is in Svelte 5 and a component has to show the resul
|
|
|
5520
5884
|
|
|
5521
5885
|
You need Svelte 5 or newer and `nanoeffects` installed. If you do not know what the bus is, read the [Guide](guide.md) first.
|
|
5522
5886
|
|
|
5887
|
+
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.
|
|
5888
|
+
|
|
5889
|
+
```js
|
|
5890
|
+
// search.js
|
|
5891
|
+
import { createBus } from "nanoeffects";
|
|
5892
|
+
import { latest } from "nanoeffects/policies";
|
|
5893
|
+
|
|
5894
|
+
export const bus = createBus(); // the bus, created once
|
|
5895
|
+
|
|
5896
|
+
// the search logic, outside the component; latest aborts the previous request
|
|
5897
|
+
bus.on(
|
|
5898
|
+
"search",
|
|
5899
|
+
latest(async (query, ctx) => {
|
|
5900
|
+
const items = await api.search(query, { signal: ctx.signal });
|
|
5901
|
+
ctx.emit("found", items);
|
|
5902
|
+
}),
|
|
5903
|
+
);
|
|
5904
|
+
```
|
|
5905
|
+
|
|
5906
|
+
The handler knows nothing about the UI, and the component only sends `search` and listens to `found`.
|
|
5907
|
+
|
|
5908
|
+
<!-- docs-check: skip - a .svelte file -->
|
|
5909
|
+
```svelte
|
|
5910
|
+
<script>
|
|
5911
|
+
import { bus } from "./search";
|
|
5912
|
+
|
|
5913
|
+
let items = $state([]);
|
|
5914
|
+
|
|
5915
|
+
// on returns the unbind function; $effect calls it on cleanup
|
|
5916
|
+
$effect(() => bus.on("found", (found) => (items = found)));
|
|
5917
|
+
</script>
|
|
5918
|
+
|
|
5919
|
+
<input oninput={(event) => bus.emit("search", event.target.value)} />
|
|
5920
|
+
<ul>
|
|
5921
|
+
{#each items as item (item.id)}
|
|
5922
|
+
<li>{item.title}</li>
|
|
5923
|
+
{/each}
|
|
5924
|
+
</ul>
|
|
5925
|
+
```
|
|
5926
|
+
|
|
5927
|
+
|
|
5523
5928
|
## Add the plugin to the bus
|
|
5524
5929
|
|
|
5525
5930
|
**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.
|
|
@@ -5700,7 +6105,7 @@ bus.on("user/open", async (id, ctx) => {
|
|
|
5700
6105
|
});
|
|
5701
6106
|
```
|
|
5702
6107
|
|
|
5703
|
-
- `signalFetch` from the same entry
|
|
6108
|
+
- 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)
|
|
5704
6109
|
- `make` runs at the first read, so a handler that never uses `ctx.http` pays nothing for it.
|
|
5705
6110
|
- A task that `ctx.run` starts builds its own value from its own signal, so aborting the child stops only the child's requests.
|
|
5706
6111
|
- The field is not copied by `{ ...ctx }` and `Object.keys(ctx)` does not list it. Read `ctx.http` first if you copy a context.
|
|
@@ -7413,25 +7818,36 @@ A policy such as `latest` cancels an older call by aborting its `ctx.signal`. Th
|
|
|
7413
7818
|
|
|
7414
7819
|
## Bind the signal once
|
|
7415
7820
|
|
|
7416
|
-
The surest fix is a handler
|
|
7821
|
+
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.
|
|
7417
7822
|
|
|
7418
7823
|
```js
|
|
7419
|
-
//
|
|
7420
|
-
import {
|
|
7421
|
-
import {
|
|
7422
|
-
import {
|
|
7423
|
-
import axios from "axios";
|
|
7824
|
+
// search-handlers.js
|
|
7825
|
+
import { latest } from "nanoeffects/policies";
|
|
7826
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
7827
|
+
import { bus } from "./bus";
|
|
7424
7828
|
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
fetch:
|
|
7429
|
-
|
|
7430
|
-
|
|
7829
|
+
bus.on(
|
|
7830
|
+
"search",
|
|
7831
|
+
latest(async (query, ctx) => {
|
|
7832
|
+
// signalFetch — fetch with ctx.signal: it stops when the policy cancels the handler
|
|
7833
|
+
const response = await signalFetch(ctx, `/api?q=${query}`);
|
|
7834
|
+
ctx.emit("results", await response.json());
|
|
7431
7835
|
}),
|
|
7432
7836
|
);
|
|
7433
7837
|
```
|
|
7434
7838
|
|
|
7839
|
+
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:
|
|
7840
|
+
|
|
7841
|
+
```js
|
|
7842
|
+
// bus.js
|
|
7843
|
+
import { createBus } from "nanoeffects";
|
|
7844
|
+
import { effectsPlugin } from "nanoeffects/effects";
|
|
7845
|
+
import { signalFetch } from "nanoeffects/signal";
|
|
7846
|
+
|
|
7847
|
+
// effectsPlugin — ctx.fetch is signalFetch bound to the task that reads it
|
|
7848
|
+
export const bus = createBus().use(effectsPlugin({ fetch: signalFetch }));
|
|
7849
|
+
```
|
|
7850
|
+
|
|
7435
7851
|
```js
|
|
7436
7852
|
// search-handlers.js
|
|
7437
7853
|
import { latest } from "nanoeffects/policies";
|
|
@@ -7439,10 +7855,27 @@ import { bus } from "./bus";
|
|
|
7439
7855
|
|
|
7440
7856
|
bus.on(
|
|
7441
7857
|
"search",
|
|
7442
|
-
latest(async (query,
|
|
7443
|
-
// no signal
|
|
7444
|
-
const response = await
|
|
7445
|
-
|
|
7858
|
+
latest(async (query, { fetch, emit }) => {
|
|
7859
|
+
// no signal and no ctx argument: fetch stops when the policy cancels the handler
|
|
7860
|
+
const response = await fetch(`/api?q=${query}`);
|
|
7861
|
+
emit("results", await response.json());
|
|
7862
|
+
}),
|
|
7863
|
+
);
|
|
7864
|
+
```
|
|
7865
|
+
|
|
7866
|
+
A client that is not an effect, such as an `axios` instance, is made from the signal of each task with `fromSignal`:
|
|
7867
|
+
|
|
7868
|
+
```js
|
|
7869
|
+
// bus.js
|
|
7870
|
+
import { createBus } from "nanoeffects";
|
|
7871
|
+
import { contextPlugin } from "nanoeffects/context";
|
|
7872
|
+
import { fromSignal } from "nanoeffects/signal";
|
|
7873
|
+
import axios from "axios";
|
|
7874
|
+
|
|
7875
|
+
// fromSignal — ctx.http is made from the signal of the task that reads it
|
|
7876
|
+
export const bus = createBus().use(
|
|
7877
|
+
contextPlugin({
|
|
7878
|
+
http: fromSignal((signal) => axios.create({ signal })),
|
|
7446
7879
|
}),
|
|
7447
7880
|
);
|
|
7448
7881
|
```
|
|
@@ -7514,7 +7947,7 @@ Each row is a situation where the signal is easy to lose. The right column says
|
|
|
7514
7947
|
| 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 | |
|
|
7515
7948
|
| The signal is wired by hand: `signal.addEventListener("abort", ...)` | Nothing, the handler is not checked step by step | accepted | |
|
|
7516
7949
|
| `bus.emit` in a handler | Use `ctx.emit`: only it is dropped after the handler was cancelled | yes | |
|
|
7517
|
-
| A request that keeps running after the cancel, on Node | Pass the signal, or use `ctx
|
|
7950
|
+
| A request that keeps running after the cancel, on Node | Pass the signal, or use `signalFetch(ctx, url)` | | yes |
|
|
7518
7951
|
| Results emitted after the cancel | The handler ignored the signal. Pass it, or check `ctx.signal.aborted` | | yes |
|
|
7519
7952
|
| `ctx.on` after the cancel | The same | | yes |
|
|
7520
7953
|
|
package/llms.txt
CHANGED
|
@@ -23,7 +23,7 @@ Rules that generated code often gets wrong:
|
|
|
23
23
|
|
|
24
24
|
## Async UI and unreliable networks
|
|
25
25
|
|
|
26
|
-
- [Policies](docs/policies.md): `latest`, `leading`, `debounce`, `throttle`, `queue`, and `key`; `withPolicy` makes one a plain function without a bus
|
|
26
|
+
- [Policies](docs/policies.md): `latest`, `leading`, `debounce`, `throttle`, `queue`, `dedupe`, `distinct`, `rateLimit`, `batch`, and `key`; `withPolicy` makes one a plain function without a bus
|
|
27
27
|
- [Cancel stale requests and fix race conditions](docs/cancel-stale-requests.md): an older response overwrites a newer one; `latest`, `debounce`, `retry`, `timeout` in one recipe
|
|
28
28
|
- [Pause between requests](docs/debounce-and-abort.md): `debounce` outside, `latest` inside, and when to use `throttle`
|
|
29
29
|
- [Prevent a double submit](docs/prevent-double-submit.md): `leading`, one run per item with `key`, retry only on network errors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nanoeffects",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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",
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
{
|
|
227
227
|
"name": "policies (all)",
|
|
228
228
|
"path": "dist/policies.js",
|
|
229
|
-
"limit": "
|
|
229
|
+
"limit": "3.71 kB"
|
|
230
230
|
},
|
|
231
231
|
{
|
|
232
232
|
"name": "effects (all)",
|
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
{
|
|
247
247
|
"name": "signal (plugin)",
|
|
248
248
|
"path": "dist/signal.js",
|
|
249
|
-
"limit": "0.
|
|
249
|
+
"limit": "0.81 kB"
|
|
250
250
|
},
|
|
251
251
|
{
|
|
252
252
|
"name": "context (plugin)",
|