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.
- package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-jeEqIKgK.d.ts} +34 -32
- package/dist/actions.d.ts +18 -18
- package/dist/actions.js +1 -1
- package/dist/bus-0uYn_kRj.js +1 -0
- package/dist/context-DtUnzC-1.js +1 -0
- package/dist/context-types-Bme1mzkn.d.ts +31 -0
- package/dist/context.d.ts +8 -3
- package/dist/context.js +1 -1
- package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
- package/dist/dev.d.ts +2 -2
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/match.d.ts +3 -3
- package/dist/match.js +1 -1
- package/dist/{module-C_oEXw1n.d.ts → module-BWMW83qk.d.ts} +38 -38
- package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-xpQ_I2h8.d.ts} +34 -32
- package/dist/notrace/actions.d.ts +52 -52
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +2 -2
- package/dist/notrace/index.js +1 -1
- package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-6LhNx0th.d.ts} +1 -1
- package/dist/notrace/policies.d.ts +1 -1
- package/dist/notrace/redux.d.ts +1 -1
- package/dist/observable.d.ts +43 -1
- package/dist/observable.js +1 -1
- package/dist/onAbort-BgMfspK4.js +1 -0
- package/dist/oop.d.ts +3 -3
- package/dist/oop.js +1 -1
- package/dist/{plugin-CuvIgjnX.d.ts → plugin-BaErevLy.d.ts} +2 -2
- package/dist/policies.d.ts +2 -2
- package/dist/policies.js +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/redux.d.ts +1 -1
- package/dist/scope.d.ts +9 -9
- package/dist/signal-field-key-CWT-3OXC.js +1 -0
- package/dist/signal.d.ts +34 -6
- package/dist/signal.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/trace.d.ts +20 -5
- package/dist/trace.js +1 -1
- package/dist/{types-FV8fuvI9.d.ts → types-D5xsR37j.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-BZVRBUOm.d.ts} +1 -1
- package/dist/{with-policy-D-SQRkPh.js → with-policy-C6BIiPIi.js} +1 -1
- package/docs/README.md +13 -1
- package/docs/actions.md +27 -1
- package/docs/chat-example.md +12 -0
- package/docs/chat-widget.md +7 -7
- package/docs/context.md +33 -1
- package/docs/effects.md +20 -9
- package/docs/guide.md +20 -2
- package/docs/migrating-from-effector.md +11 -0
- package/docs/observable.md +43 -0
- package/docs/oop.md +17 -0
- package/docs/package.md +22 -13
- package/docs/plugins.md +69 -11
- package/docs/query-libraries.md +273 -5
- package/docs/react.md +12 -2
- package/docs/scope.md +14 -2
- package/docs/semantics.md +10 -0
- package/docs/server.md +3 -3
- package/docs/stability.md +2 -2
- package/docs/tracing.md +74 -6
- package/docs/typescript.md +1 -1
- package/llms-full.txt +671 -66
- package/llms.txt +7 -7
- package/package.json +4 -4
- package/skills/nanoeffects/SKILL.md +2 -2
- package/dist/bus-DlpEluzd.js +0 -1
- package/dist/context-UcmrAIhP.js +0 -1
- package/dist/context-types-hCKgU8sC.d.ts +0 -14
package/docs/query-libraries.md
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
# Use nanoeffects with React Query and
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
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
|
|
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 —
|
|
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
|
|
76
|
-
const user = await retry(ctx, (
|
|
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
|
|
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 ·
|
|
118
|
-
[nanoeffects] user/save ·
|
|
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 ·
|
|
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 ·
|
|
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
|
|
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:
|
package/docs/typescript.md
CHANGED
|
@@ -23,7 +23,7 @@ export const getUser = createAction(
|
|
|
23
23
|
},
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
-
// createModule: m.use binds the handler the
|
|
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
|
});
|