nanoeffects 0.28.0 → 0.29.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 (76) hide show
  1. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-jeEqIKgK.d.ts} +34 -32
  2. package/dist/actions.d.ts +18 -18
  3. package/dist/actions.js +1 -1
  4. package/dist/bus-0uYn_kRj.js +1 -0
  5. package/dist/context-DtUnzC-1.js +1 -0
  6. package/dist/context-types-Bme1mzkn.d.ts +31 -0
  7. package/dist/context.d.ts +8 -3
  8. package/dist/context.js +1 -1
  9. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  10. package/dist/dev.d.ts +2 -2
  11. package/dist/effects.d.ts +2 -2
  12. package/dist/effects.js +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/match.d.ts +3 -3
  16. package/dist/match.js +1 -1
  17. package/dist/{module-C_oEXw1n.d.ts → module-BWMW83qk.d.ts} +38 -38
  18. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-xpQ_I2h8.d.ts} +34 -32
  19. package/dist/notrace/actions.d.ts +52 -52
  20. package/dist/notrace/actions.js +1 -1
  21. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  22. package/dist/notrace/effects.d.ts +2 -2
  23. package/dist/notrace/index.d.ts +2 -2
  24. package/dist/notrace/index.js +1 -1
  25. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-6LhNx0th.d.ts} +1 -1
  26. package/dist/notrace/policies.d.ts +1 -1
  27. package/dist/notrace/redux.d.ts +1 -1
  28. package/dist/observable.d.ts +43 -1
  29. package/dist/observable.js +1 -1
  30. package/dist/onAbort-BgMfspK4.js +1 -0
  31. package/dist/oop.d.ts +3 -3
  32. package/dist/oop.js +1 -1
  33. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BaErevLy.d.ts} +2 -2
  34. package/dist/policies.d.ts +2 -2
  35. package/dist/policies.js +1 -1
  36. package/dist/react.d.ts +2 -2
  37. package/dist/react.js +1 -1
  38. package/dist/redux.d.ts +1 -1
  39. package/dist/scope.d.ts +9 -9
  40. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  41. package/dist/signal.d.ts +34 -6
  42. package/dist/signal.js +1 -1
  43. package/dist/svelte.d.ts +1 -1
  44. package/dist/trace.d.ts +20 -5
  45. package/dist/trace.js +1 -1
  46. package/dist/{types-FV8fuvI9.d.ts → types-D5xsR37j.d.ts} +2 -2
  47. package/dist/vue.d.ts +1 -1
  48. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-BZVRBUOm.d.ts} +1 -1
  49. package/dist/{with-policy-D-SQRkPh.js → with-policy-C6BIiPIi.js} +1 -1
  50. package/docs/README.md +13 -1
  51. package/docs/actions.md +27 -1
  52. package/docs/chat-example.md +12 -0
  53. package/docs/chat-widget.md +7 -7
  54. package/docs/context.md +33 -1
  55. package/docs/effects.md +20 -9
  56. package/docs/guide.md +20 -2
  57. package/docs/migrating-from-effector.md +11 -0
  58. package/docs/observable.md +43 -0
  59. package/docs/oop.md +17 -0
  60. package/docs/package.md +22 -13
  61. package/docs/plugins.md +69 -11
  62. package/docs/query-libraries.md +273 -5
  63. package/docs/react.md +12 -2
  64. package/docs/scope.md +14 -2
  65. package/docs/semantics.md +10 -0
  66. package/docs/server.md +3 -3
  67. package/docs/stability.md +2 -2
  68. package/docs/tracing.md +74 -6
  69. package/docs/typescript.md +1 -1
  70. package/llms-full.txt +671 -66
  71. package/llms.txt +7 -7
  72. package/package.json +4 -4
  73. package/skills/nanoeffects/SKILL.md +2 -2
  74. package/dist/bus-DlpEluzd.js +0 -1
  75. package/dist/context-UcmrAIhP.js +0 -1
  76. package/dist/context-types-hCKgU8sC.d.ts +0 -14
@@ -1,8 +1,21 @@
1
- # Use nanoeffects with React Query and SWR
1
+ # Use nanoeffects with React Query, SWR and RTK Query
2
2
 
3
- Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query) or [SWR](https://swr.vercel.app) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one action that both libraries call.
3
+ Read this page if you fetch data with [React Query](https://tanstack.com/query) (TanStack Query), [SWR](https://swr.vercel.app) or [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) and want `retry`, `timeout` and the abort of nanoeffects inside the request. It shows one action that the libraries call, then three recipes for the parts around it: a client that stops with the request, a live stream into the cache, and a refetch on a bus event.
4
4
 
5
- You need a React project, `nanoeffects`, and one of the two libraries. `api` below is your own client: replace it with your requests.
5
+ You need a React project, `nanoeffects`, and one of the libraries. `api` below is your own client: replace it with your requests.
6
+
7
+ ## On this page
8
+
9
+ - [Decide who owns what](#decide-who-owns-what)
10
+ - [Make the action](#make-the-action)
11
+ - [Call it from React Query](#call-it-from-react-query)
12
+ - [Call it from SWR](#call-it-from-swr)
13
+ - [Let the bus know the result](#let-the-bus-know-the-result)
14
+ - [Call it from RTK Query](#call-it-from-rtk-query)
15
+ - [Give the client its signal once](#give-the-client-its-signal-once)
16
+ - [Push a live stream into the cache](#push-a-live-stream-into-the-cache)
17
+ - [Refetch queries when the bus says so](#refetch-queries-when-the-bus-says-so)
18
+ - [Know the limits](#know-the-limits)
6
19
 
7
20
  ## Decide who owns what
8
21
 
@@ -63,7 +76,7 @@ import { getUser } from "./user";
63
76
  export function Profile({ id }) {
64
77
  const { isLoading, data, error } = useQuery({
65
78
  queryKey: ["user", id],
66
- // emit — runs the command; { signal } ties its task to the signal of React Query
79
+ // emit — runs the action; { signal } ties its task to the signal of React Query
67
80
  queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
68
81
  });
69
82
 
@@ -115,9 +128,264 @@ export const userLoaded = createAction("user/loaded");
115
128
 
116
129
  Send it from the handler with `ctx.emit(userLoaded(user))`. Do not replace the action with `createJob` here: a job handles the error itself and resolves with `undefined`, so React Query would never see the failure. Use a job when nothing waits for the result and the app shows a loading state from the bus, see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job).
117
130
 
131
+ ## Call it from RTK Query
132
+
133
+ <!-- docs-check: new -->
134
+
135
+ RTK Query gives the `queryFn` of an endpoint a `signal` in its second argument, so the call is the same as with React Query: pass it to `emit`. This page does not set up the store; see [Stores](stores.md) for that.
136
+
137
+ ```js
138
+ // bus.js
139
+ import { createBus } from "nanoeffects";
140
+ import { signalPlugin } from "nanoeffects/signal";
141
+
142
+ // signalPlugin — lets emit take { signal }
143
+ export const bus = createBus().use(signalPlugin());
144
+ ```
145
+
146
+ ```js
147
+ // user.js
148
+ import { createAction } from "nanoeffects/actions";
149
+
150
+ // createAction with a handler — emit returns what the handler returns
151
+ export const getUser = createAction("user/get", (id, { signal }) =>
152
+ api.getUser(id, { signal }),
153
+ );
154
+ ```
155
+
156
+ ```js
157
+ // api-slice.js
158
+ import { createApi, fakeBaseQuery } from "@reduxjs/toolkit/query/react";
159
+ import { bus } from "./bus";
160
+ import { getUser } from "./user";
161
+
162
+ export const userApi = createApi({
163
+ reducerPath: "userApi",
164
+ baseQuery: fakeBaseQuery(), // RTK Query — every endpoint brings its own queryFn
165
+ endpoints: (build) => ({
166
+ getUser: build.query({
167
+ // emit — runs the action; { signal } ties its task to the request of RTK Query
168
+ queryFn: async (id, { signal }) => {
169
+ const user = await bus.emit(getUser(id), { signal });
170
+ return { data: user };
171
+ },
172
+ }),
173
+ }),
174
+ });
175
+ ```
176
+
177
+ ## Give the client its signal once
178
+
179
+ <!-- docs-check: new -->
180
+
181
+ Problem: the action passes `signal` to every call of `api`, and one forgotten `{ signal }` leaves a request running after the query is gone. `fromSignal` builds the client from the signal of each task, so the handler cannot forget it. Use it when many actions share one client. A single action is simpler as it is in [Make the action](#make-the-action).
182
+
183
+ `fromSignal` marks a field of `contextPlugin`. The client is made when a handler first reads `ctx.http`, from the `ctx.signal` of that task. More in [Context fields](context.md#build-a-field-from-the-signal-of-each-task).
184
+
185
+ ```js
186
+ // bus.js
187
+ import axios from "axios";
188
+ import { createBus } from "nanoeffects";
189
+ import { contextPlugin } from "nanoeffects/context";
190
+ import { fromSignal, signalPlugin } from "nanoeffects/signal";
191
+
192
+ // fromSignal — http is a new axios client for each task, bound to the signal of that task
193
+ const http = fromSignal((signal) => axios.create({ baseURL: "/api", signal }));
194
+
195
+ // contextPlugin — every handler gets ctx.http
196
+ const context = contextPlugin({ http });
197
+
198
+ // signalPlugin — emit takes { signal }, so the signal of the query reaches the task
199
+ export const bus = createBus().use(signalPlugin()).use(context);
200
+ ```
201
+
202
+ ```js
203
+ // user.js
204
+ import { createAction } from "nanoeffects/actions";
205
+
206
+ // the handler never sees a signal: ctx.http stops when the task aborts
207
+ export const getUser = createAction("user/get", async (id, { http }) => {
208
+ const response = await http.get(`/users/${id}`);
209
+ return response.data;
210
+ });
211
+ ```
212
+
213
+ ```jsx
214
+ // Profile.jsx
215
+ import { useQuery } from "@tanstack/react-query";
216
+ import { bus } from "./bus";
217
+ import { getUser } from "./user";
218
+
219
+ export function Profile({ id }) {
220
+ const { data } = useQuery({
221
+ queryKey: ["user", id],
222
+ // emit — the signal of React Query aborts the task, and with it the request of ctx.http
223
+ queryFn: ({ signal }) => bus.emit(getUser(id), { signal }),
224
+ });
225
+
226
+ return <p>{data?.name}</p>;
227
+ }
228
+ ```
229
+
230
+ ## Push a live stream into the cache
231
+
232
+ <!-- docs-check: new -->
233
+
234
+ Problem: prices arrive from a socket, and the component should show the newest one without a refetch. The socket code emits an event on the bus; the component reads those events with `events` and writes each into the cache. Both libraries have an entry point for pushed data: `setQueryData` in React Query and `useSWRSubscription` in SWR.
235
+
236
+ `events` gives the payloads to a `for await` loop. `buffer: 1` keeps only the newest price while a write is running. The abort in the cleanup ends the loop and removes the listener. More in [Read an event with a `for await` loop](observable.md#read-an-event-with-a-for-await-loop).
237
+
238
+ ```js
239
+ // bus.js
240
+ import { createBus } from "nanoeffects";
241
+
242
+ // createBus — the bus the socket code and the components share
243
+ export const bus = createBus();
244
+ ```
245
+
246
+ ```js
247
+ // prices.js
248
+ import { createAction } from "nanoeffects/actions";
249
+
250
+ // createAction — the event the socket code emits for every new price
251
+ export const priceChanged = createAction("price/changed");
252
+ ```
253
+
254
+ ```jsx
255
+ // Price.jsx
256
+ import { useEffect } from "react";
257
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
258
+ import { events } from "nanoeffects/observable";
259
+ import { bus } from "./bus";
260
+ import { priceChanged } from "./prices";
261
+
262
+ export function Price({ symbol }) {
263
+ const queryClient = useQueryClient();
264
+ const { data } = useQuery({
265
+ queryKey: ["price", symbol],
266
+ queryFn: () => api.getPrice(symbol), // the first value; the stream keeps it fresh
267
+ });
268
+
269
+ useEffect(() => {
270
+ const controller = new AbortController();
271
+ // events — the newest price event, one at a time
272
+ const prices = events(bus, priceChanged, {
273
+ signal: controller.signal,
274
+ buffer: 1,
275
+ });
276
+
277
+ async function follow() {
278
+ for await (const price of prices) {
279
+ if (price.symbol === symbol) {
280
+ queryClient.setQueryData(["price", symbol], price.value); // write into the cache
281
+ }
282
+ }
283
+ }
284
+
285
+ follow().catch((error) => {
286
+ if (!controller.signal.aborted) throw error; // the abort ends the loop; anything else is a bug
287
+ });
288
+
289
+ return () => controller.abort(); // the component left: the loop ends, the listener goes
290
+ }, [queryClient, symbol]);
291
+
292
+ return <p>{data}</p>;
293
+ }
294
+ ```
295
+
296
+ SWR does the same with `useSWRSubscription` from `swr/subscription`. Its subscribe function gets `next` to push a value and returns the cleanup:
297
+
298
+ ```jsx
299
+ // Price.jsx
300
+ import useSWRSubscription from "swr/subscription";
301
+ import { events } from "nanoeffects/observable";
302
+ import { bus } from "./bus";
303
+ import { priceChanged } from "./prices";
304
+
305
+ export function Price({ symbol }) {
306
+ const { data } = useSWRSubscription(["price", symbol], (_key, { next }) => {
307
+ const controller = new AbortController();
308
+ // events — the newest price event, one at a time
309
+ const prices = events(bus, priceChanged, {
310
+ signal: controller.signal,
311
+ buffer: 1,
312
+ });
313
+
314
+ async function follow() {
315
+ for await (const price of prices) {
316
+ if (price.symbol === symbol) {
317
+ next(null, price.value); // SWR — push the value to the hook
318
+ }
319
+ }
320
+ }
321
+
322
+ follow().catch((error) => {
323
+ if (!controller.signal.aborted) next(error);
324
+ });
325
+
326
+ return () => controller.abort(); // SWR calls this when the last component of the key leaves
327
+ });
328
+
329
+ return <p>{data}</p>;
330
+ }
331
+ ```
332
+
333
+ ## Refetch queries when the bus says so
334
+
335
+ <!-- docs-check: new -->
336
+
337
+ Problem: a save in one part of the app should refresh the lists in another. The save emits an event, and the component that owns the queries listens for it. One `AbortController` ends every listener of the component, instead of one `unbind` for each. For a single listener, `return bus.on(…)` from the effect is shorter.
338
+
339
+ This needs `signalPlugin`, because `{ signal }` in the options of `on` is read by the plugin. Without it the listeners stay after the component leaves.
340
+
341
+ ```js
342
+ // bus.js
343
+ import { createBus } from "nanoeffects";
344
+ import { signalPlugin } from "nanoeffects/signal";
345
+
346
+ // signalPlugin — on takes { signal } and removes the listener when it aborts
347
+ export const bus = createBus().use(signalPlugin());
348
+ ```
349
+
350
+ ```js
351
+ // user-events.js
352
+ import { createAction } from "nanoeffects/actions";
353
+
354
+ // createAction — events the save code emits
355
+ export const userSaved = createAction("user/saved");
356
+ export const userDeleted = createAction("user/deleted");
357
+ ```
358
+
359
+ ```jsx
360
+ // UserList.jsx
361
+ import { useEffect } from "react";
362
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
363
+ import { bus } from "./bus";
364
+ import { userDeleted, userSaved } from "./user-events";
365
+
366
+ export function UserList() {
367
+ const queryClient = useQueryClient();
368
+ const { data } = useQuery({ queryKey: ["users"], queryFn: () => api.listUsers() });
369
+
370
+ useEffect(() => {
371
+ const controller = new AbortController();
372
+ const { signal } = controller;
373
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ["users"] });
374
+
375
+ // on with { signal } — both listeners go when the component leaves
376
+ bus.on(userSaved, refresh, { signal });
377
+ bus.on(userDeleted, refresh, { signal });
378
+
379
+ return () => controller.abort();
380
+ }, [queryClient]);
381
+
382
+ return <ul>{data?.map((user) => <li key={user.id}>{user.name}</li>)}</ul>;
383
+ }
384
+ ```
385
+
118
386
  ## Know the limits
119
387
 
120
- - **Without the plugin, `{ signal }` is ignored.** The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
388
+ - **Without the plugin, `{ signal }` is ignored,** in `emit` and in the options of `on`. The action runs, but the abort does not reach it. Call `bus.use(signalPlugin())` once, where the bus is made.
121
389
  - **Only the call forms take it.** `bus.emit(getUser(id), { signal })` works; `bus.emit("user/get", id, { signal })` does not.
122
390
  - **A handler that returns without a promise is finished with the call**, so a signal that aborts later does not reach it. Actions that fetch are async, so this only matters for a sync handler that sets up a callback.
123
391
  - **Give each request its own signal for an event.** For an action with a handler or a job the link ends when it settles. For `bus.emit(action, { signal })` it stays until the signal aborts.
package/docs/react.md CHANGED
@@ -4,6 +4,16 @@ Use this page when a React screen starts requests on user input and you need the
4
4
 
5
5
  You need a React project and `nanoeffects` installed. The form talks to your own client, `api` below: replace it with your requests.
6
6
 
7
+ ## On this page
8
+
9
+ - [See what goes wrong without a bus](#see-what-goes-wrong-without-a-bus)
10
+ - [Wire the bus once](#wire-the-bus-once)
11
+ - [Connect the form](#connect-the-form)
12
+ - [Stop running work when the form closes](#stop-running-work-when-the-form-closes)
13
+ - [Show the last value of an event as state](#show-the-last-value-of-an-event-as-state)
14
+ - [Keep a debounced handler between renders](#keep-a-debounced-handler-between-renders)
15
+ - [Where to go next](#where-to-go-next)
16
+
7
17
  ## See what goes wrong without a bus
8
18
 
9
19
  A registration form has three problems that `useEffect` and `fetch` leave to you:
@@ -75,7 +85,7 @@ The handlers know nothing about React. A test can emit `username/typed` and read
75
85
 
76
86
  <!-- docs-check: continue -->
77
87
 
78
- The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount.
88
+ The component keeps what it shows in `useState`. It subscribes in `useEffect` and emits events from its handlers. `bus.on` returns the function that unsubscribes; returning it from the effect removes the listener on unmount. If the component already holds an `AbortSignal`, `bus.on(event, handler, { signal })` ends the listener with it; see [`signalPlugin`](plugins.md#tie-an-emit-or-a-listener-to-an-outside-signal-signalplugin).
79
89
 
80
90
  ```jsx
81
91
  // SignUpForm.jsx
@@ -276,4 +286,4 @@ export const Search = ({ wait }) => {
276
286
  - A policy as a plain function without a bus: [Use a policy without a bus](policies.md#use-a-policy-without-a-bus).
277
287
  - `retry`, `timeout` and the other effects: [Effects](effects.md).
278
288
  - A state library instead of `useState`: [Stores](stores.md).
279
- - React Query or SWR instead of your own state: [Use nanoeffects with React Query and SWR](query-libraries.md).
289
+ - React Query or SWR instead of your own state: [Use nanoeffects with React Query, SWR and RTK Query](query-libraries.md).
package/docs/scope.md CHANGED
@@ -6,6 +6,18 @@ A **scope** is a plain object. Every key goes on `ctx` as it is (`cache`, `api`,
6
6
 
7
7
  You give a scope to the thing that uses it. A call can replace it. Nothing else changes: handlers still get `(payload, ctx)`.
8
8
 
9
+ ## On this page
10
+
11
+ - [Turn scopes on](#turn-scopes-on)
12
+ - [Give an action its own fields](#give-an-action-its-own-fields)
13
+ - [Bind an effect to the task](#bind-an-effect-to-the-task)
14
+ - [Replace a field for one call](#replace-a-field-for-one-call)
15
+ - [Give an action, a job or a module its own fields](#give-an-action-a-job-or-a-module-its-own-fields)
16
+ - [Which value wins](#which-value-wins)
17
+ - [Keep a generic effect generic](#keep-a-generic-effect-generic)
18
+ - [Type the fields (optional)](#type-the-fields-optional)
19
+ - [Limits](#limits)
20
+
9
21
  ## Turn scopes on
10
22
 
11
23
  Scopes are a plugin, so a bus that does not use them pays nothing.
@@ -36,7 +48,7 @@ import { createAction } from "nanoeffects/actions";
36
48
  export const getUser = createAction(
37
49
  "user/get",
38
50
  (id, { api, cache }) => {
39
- // api and cache are on ctx for this command only
51
+ // api and cache are on ctx for this action only
40
52
  return cache.get(id) ?? api.getUser(id);
41
53
  },
42
54
  { api, cache: new Map() },
@@ -185,7 +197,7 @@ When several places set the same field, the strongest one wins:
185
197
  | Strength | Where it is set |
186
198
  | -------- | --------------- |
187
199
  | 1 (strongest) | the second argument of `emit` |
188
- | 2 | the scope of the action, command or job |
200
+ | 2 | the scope of the action or job |
189
201
  | 3 | the scope of the module that owns the handler |
190
202
  | 4 (weakest) | the bus: `createBus({ context })`, `contextPlugin`, `effectsPlugin` |
191
203
 
package/docs/semantics.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  Guarantees and sharp edges. Read when something surprises you; not required to start.
4
4
 
5
+ ## Five rules to know first
6
+
7
+ 1. A handler is `(payload, ctx)`. Use `ctx.signal`, `ctx.emit` and `ctx.on`; the rest of `ctx` is optional.
8
+ 2. Pass `ctx.signal` to what the handler calls (`fetch`, a client), so an abort stops the work.
9
+ 3. An aborted task delivers no result and emits nothing.
10
+ 4. `emit` is synchronous: the first handler error is thrown, unless `onError` is set.
11
+ 5. Rejections of async handlers are not handled without `onError`.
12
+
13
+ Details of each rule and the rest:
14
+
5
15
  - **An aborted task delivers no result.** If the task aborts before a promise settles, the promise rejects with the abort reason, even if the value was just produced in the same tick. `latest`, `debounce`, `all`, `race`, `take`, and the rest follow this rule.
6
16
  - **`emit` is a plain, synchronous loop** (see [Errors](errors.md)). A `ctx.emit` inside a handler is the same call: without `onError`, an error of a nested handler is thrown into the handler that emitted; with `onError` it goes there and `ctx.emit` does not throw.
7
17
  - **Rejections of async handlers** are not handled for you. Without `onError` they are unhandled rejections, which end a Node process by default. Use `onError` for async handlers.
package/docs/server.md CHANGED
@@ -37,7 +37,7 @@ The action loads a user. It tries the request up to three times, and gives up on
37
37
  import { createAction } from "nanoeffects/actions";
38
38
  import { retry, timeout } from "nanoeffects/effects";
39
39
 
40
- // createAction — a command with a handler: emit returns what the handler returns
40
+ // createAction — an action with a handler: emit returns what the handler returns
41
41
  export const getUser = createAction("user/get", async (id, ctx) => {
42
42
  // one try: the request, with the signal of timeout so it can stop it
43
43
  const load = ({ signal }) => api.getUser(id, { signal });
@@ -78,7 +78,7 @@ app.get("/users/:id", async (request, reply) => {
78
78
  });
79
79
 
80
80
  try {
81
- // emit with { signal } — aborting the controller aborts the command: timeout, retry and the request
81
+ // emit with { signal } — aborting the controller aborts the action: timeout, retry and the request
82
82
  const call = getUser(request.params.id);
83
83
  return await bus.emit(call, { signal: controller.signal });
84
84
  } catch (error) {
@@ -131,7 +131,7 @@ import { createAction } from "nanoeffects/actions";
131
131
  import { poll } from "nanoeffects/effects";
132
132
  import { latest } from "nanoeffects/policies";
133
133
 
134
- // createAction — a command with a handler
134
+ // createAction — an action with a handler
135
135
  // latest — a new subscription stops the old one; key — but only the old one of the same socket
136
136
  export const watchPrices = createAction(
137
137
  "prices/watch",
package/docs/stability.md CHANGED
@@ -72,8 +72,8 @@ import { race, retry } from "nanoeffects/effects";
72
72
  const bus = createBus();
73
73
 
74
74
  bus.on("open", async (id, ctx) => {
75
- // with ctx: you pass the context, the function gets the signal
76
- const user = await retry(ctx, ({ signal }) => fetchUser(id, { signal }), { times: 3 });
75
+ // with ctx: you pass the context, and the function takes the signal from it
76
+ const user = await retry(ctx, () => fetchUser(id, { signal: ctx.signal }), { times: 3 });
77
77
 
78
78
  // as a starter: a group (race, all, allSettled, timeout) calls it with its child
79
79
  const result = await race(ctx, {
package/docs/tracing.md CHANGED
@@ -4,6 +4,22 @@ Find out what your app did and why: which events were emitted, which calls a pol
4
4
 
5
5
  Tracing is optional. A bus works the same without it, and it costs almost nothing until you turn it on. If you only want to see what happens while you develop, jump to [Show every emit](#show-every-emit) and [Configure Redux DevTools](#configure-redux-devtools); the rest of the page is for apps that need to follow a flow or turn tracing off in a build.
6
6
 
7
+ ## On this page
8
+
9
+ - [Attach a tracer](#attach-a-tracer)
10
+ - [Show every emit](#show-every-emit)
11
+ - [Show which subscription began a chain](#show-which-subscription-began-a-chain)
12
+ - [Follow one flow with a traceGroupId](#follow-one-flow-with-a-tracegroupid)
13
+ - [Name a listener](#name-a-listener)
14
+ - [Keep the chain: `ctx.emit`, not `bus.emit`](#keep-the-chain-ctxemit-not-busemit)
15
+ - [Turn the names off](#turn-the-names-off)
16
+ - [Use built-in loggers](#use-built-in-loggers)
17
+ - [Cut volume in production](#cut-volume-in-production)
18
+ - [Keep the last events for an incident](#keep-the-last-events-for-an-incident)
19
+ - [Configure Redux DevTools](#configure-redux-devtools)
20
+ - [Write your own logger](#write-your-own-logger)
21
+ - [Drop tracing from a build](#drop-tracing-from-a-build)
22
+
7
23
  ## Attach a tracer
8
24
 
9
25
  Pass `trace` to `createBus`. Make it with `createTracer` from `nanoeffects/trace`, which takes a logger (a function) or an array of them. The tracing code lives in that entry, so a bus without `trace` does not carry it.
@@ -86,7 +102,7 @@ The event changes nothing else: the other tasks keep their `parentId`, `rootId`
86
102
 
87
103
  ## Show which subscription began a chain
88
104
 
89
- **Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of commands and retries.
105
+ **Problem.** `useEmitter` and `bus.emitter` put an event on the bus from outside any task, such as a render or the callback of a store. In the trace that event has no sign of where it came from. With many such subscriptions it is hard to tell which one started a chain of actions and retries.
90
106
 
91
107
  **When.** The bus has a tracer and the project has several `useEmitter` or `bus.emitter` calls. The names come from `tracePlugin`: use it on the bus, or the `traceId` is ignored.
92
108
 
@@ -114,8 +130,8 @@ In a component, the same option goes in the options of the hook: `useEmitter(nam
114
130
 
115
131
  ```
116
132
  [nanoeffects] Profile · run start
117
- [nanoeffects] user/save · command start
118
- [nanoeffects] user/save · command done
133
+ [nanoeffects] user/save · action start
134
+ [nanoeffects] user/save · action done
119
135
  [nanoeffects] Profile · run done
120
136
  ```
121
137
 
@@ -155,7 +171,7 @@ const pay = createAction("checkout/pay", async () => {}, {
155
171
  ```
156
172
 
157
173
  ```
158
- [nanoeffects] [checkout] checkout/pay · command start
174
+ [nanoeffects] [checkout] checkout/pay · action start
159
175
  ```
160
176
 
161
177
  The emitters take it too: `useEmitter(cartChanged, cart, { traceId: "Cart", traceGroupId: "checkout" })`. `traceGroupId` works without `scopePlugin`, and needs `tracePlugin`.
@@ -185,7 +201,7 @@ bus.once("name/changed", (name) => console.log(name), { traceId: "auditLog" });
185
201
 
186
202
  ```
187
203
  [nanoeffects] saveNotifier · run start
188
- [nanoeffects] user/save · command start
204
+ [nanoeffects] user/save · action start
189
205
  [nanoeffects] saveNotifier · run done
190
206
  ```
191
207
 
@@ -230,7 +246,7 @@ const bus = createBus({
230
246
  | `reduxDevtools()` | Sends the emits to Redux DevTools, and with `policies` the stages of the handlers as `type/effect/phase`; state is `{ running }` |
231
247
  | `consoleLogger()` | Prints one line per phase, indented by task depth |
232
248
  | `performanceLogger()` | User Timing measures for the browser Performance panel |
233
- | `createTraceRecorder()` | Keeps events in `recorder.events`, for tests |
249
+ | `createTraceRecorder()` | Keeps events in `recorder.events`: all of them for tests, or with `{ capacity }` the last N ([see below](#keep-the-last-events-for-an-incident)) |
234
250
 
235
251
  ### Cut volume in production
236
252
 
@@ -246,8 +262,60 @@ createTracer(logger, {
246
262
  - `sample` is decided once per root task; every task below it follows, so a trace is never cut in the middle. Unsampled tasks cost almost nothing.
247
263
  - `filter` runs on each event before any logger sees it.
248
264
 
265
+ ### Keep the last events for an incident
266
+
267
+ `createTraceRecorder()` with no option keeps every event, so a long session fills memory. That is fine in a test and wrong in a running app. Give it a `capacity` and it keeps only the last N events (a ring buffer): when something goes wrong, you still have what led up to it.
268
+
269
+ <!-- docs-check: new -->
270
+
271
+ ```js
272
+ // bus.js
273
+ import { createBus } from "nanoeffects";
274
+ import { createTraceRecorder, createTracer } from "nanoeffects/trace";
275
+
276
+ // A logger that keeps the last 500 events and drops the older ones.
277
+ export const recorder = createTraceRecorder({ capacity: 500 });
278
+
279
+ // Feed the recorder from the tracer.
280
+ const trace = createTracer(recorder);
281
+
282
+ export const bus = createBus({ trace });
283
+ ```
284
+
285
+ ```js
286
+ // incident.js
287
+ import { recorder } from "./bus";
288
+
289
+ // Keep what is safe to send. An event also has `payload` and `error`.
290
+ const withoutPayload = ({ id, parentId, type, effect, phase }) => ({
291
+ id,
292
+ parentId,
293
+ type,
294
+ effect,
295
+ phase,
296
+ });
297
+
298
+ window.addEventListener("error", () => {
299
+ const events = recorder.events; // a snapshot, oldest event first
300
+ const safe = events.map(withoutPayload);
301
+ const body = JSON.stringify(safe);
302
+ navigator.sendBeacon("/api/incident", body);
303
+ });
304
+ ```
305
+
306
+ What to know before you send the events:
307
+
308
+ - **`payload` and `error` can hold private data**: what a user typed, tokens, personal text. The recorder keeps them as they are and does not redact. Choose what to send, as `withoutPayload` does.
309
+ - **There is no time on an event.** You get the order, not the duration. To add a time, wrap the recorder in a logger that stamps `performance.now()` on a copy of the event.
310
+ - **An old `parentId` can point at an event that was pushed out.** When you build a tree from the events, treat an unknown `parentId` as a root.
311
+ - **`recorder.events` is a snapshot with a `capacity`.** Each read builds a new array, so read it once at the moment you need it. Without a `capacity` it is one live array.
312
+ - **A low `sample` thins the buffer.** `createTracer(recorder, { sample: 0.05 })` records 5% of root tasks, so the buffer may hold little when the incident comes. See [Cut volume in production](#cut-volume-in-production).
313
+ - **A `capacity` that is not a positive integer** (`0`, a negative number, `NaN`) means no limit.
314
+
249
315
  ### Configure Redux DevTools
250
316
 
317
+ <!-- docs-check: new -->
318
+
251
319
  Read-only (the bus owns no state, so there is no time travel). Does nothing when the extension is not installed. Has its own instance in the DevTools picker, apart from your store.
252
320
 
253
321
  Quiet by default, so you see what happened before you see how:
@@ -23,7 +23,7 @@ export const getUser = createAction(
23
23
  },
24
24
  );
25
25
 
26
- // createModule: m.use binds the handler the command carries, while the module is mounted
26
+ // createModule: m.use binds the handler the action carries, while the module is mounted
27
27
  export const userModule = createModule("user", (m) => {
28
28
  m.use(getUser);
29
29
  });