nanoeffects 0.31.2 → 0.33.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-6rlBTmH9.d.ts → action-creator-DprJV-pN.d.ts} +6 -2
- package/dist/actions.d.ts +3 -3
- package/dist/actions.js +1 -1
- package/dist/{bus-DFlmNJ6h.js → bus-Cmk4U1hQ.js} +1 -1
- package/dist/caller-DULg_ZYV.js +1 -0
- package/dist/context-Dp_QRXzb.js +1 -0
- package/dist/{context-types-DKNc5mkS.d.ts → context-types-BiTMp_lW.d.ts} +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/dev.d.ts +2 -2
- package/dist/dev.js +2 -2
- package/dist/effects.d.ts +2 -2
- package/dist/effects.js +1 -1
- package/dist/guard.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/match.d.ts +1 -1
- package/dist/match.js +1 -1
- package/dist/{module-G39MaVOt.d.ts → module-DPKjzfRk.d.ts} +2 -2
- package/dist/notrace/{action-creator-BrMSO3RP.d.ts → action-creator-BWd9FWt6.d.ts} +2 -2
- package/dist/notrace/actions.d.ts +2 -2
- package/dist/notrace/actions.js +1 -1
- package/dist/notrace/effects.d.ts +2 -2
- package/dist/notrace/index.d.ts +1 -1
- package/dist/notrace/{plugin-7GUL2EW5.d.ts → plugin-DmAYT3Xk.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 +1 -1
- package/dist/oop.d.ts +1 -1
- package/dist/oop.js +1 -1
- package/dist/{plugin-BWNP23hr.d.ts → plugin-2d3uuW7o.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 +2 -2
- package/dist/signal.d.ts +2 -2
- package/dist/signal.js +1 -1
- package/dist/svelte.d.ts +1 -1
- package/dist/testing.d.ts +80 -0
- package/dist/testing.js +1 -0
- package/dist/trace.d.ts +227 -44
- package/dist/trace.js +3 -3
- package/dist/tracer-v7m-Qah_.js +2 -0
- package/dist/{types-N_cdRr1r.d.ts → types-DxPZDzZG.d.ts} +2 -2
- package/dist/vue.d.ts +1 -1
- package/dist/{with-policy-CeM1rtES.js → with-policy-BmL0eRU3.js} +1 -1
- package/dist/{with-policy-CthiBp_z.d.ts → with-policy-Y12d-yx6.d.ts} +1 -1
- package/docs/debugging-cases.md +122 -0
- package/docs/debugging-loops.md +79 -0
- package/docs/migrating-from-rxjs.md +1 -1
- package/docs/package.md +2 -1
- package/docs/recipes.md +35 -0
- package/docs/ru/debugging-cases.md +122 -0
- package/docs/ru/debugging-loops.md +79 -0
- package/docs/ru/migrating-from-rxjs.md +1 -1
- package/docs/ru/package.md +2 -1
- package/docs/ru/recipes.md +35 -0
- package/docs/ru/testing.md +92 -0
- package/docs/ru/tracing.md +189 -3
- package/docs/testing.md +92 -0
- package/docs/tracing.md +189 -3
- package/llms-full.txt +528 -5
- package/llms.txt +4 -2
- package/package.json +12 -3
- package/dist/context-B5Z2RpJt.js +0 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Debugging cases
|
|
2
|
+
|
|
3
|
+
> **Level F.** Read first: [Trace tasks](tracing.md#attach-a-tracer).
|
|
4
|
+
|
|
5
|
+
Read this page when something goes wrong and you want to know who listens to an event, what began a chain, or why a request stopped. Each case below is a short snippet that you can run. For loops, see [Find and cut event loops](debugging-loops.md).
|
|
6
|
+
|
|
7
|
+
## On this page
|
|
8
|
+
|
|
9
|
+
- [Find who listens to an event](#find-who-listens-to-an-event)
|
|
10
|
+
- [Find which task began a chain](#find-which-task-began-a-chain)
|
|
11
|
+
- [Find out why a request stopped](#find-out-why-a-request-stopped)
|
|
12
|
+
- [Know what is missing](#know-what-is-missing)
|
|
13
|
+
|
|
14
|
+
## Find who listens to an event
|
|
15
|
+
|
|
16
|
+
**Problem.** You read an emit and want to know what it starts.
|
|
17
|
+
|
|
18
|
+
Name the listeners with functions or a `traceId`, use `tracePlugin`, and call `describeBus`:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { createBus } from "nanoeffects";
|
|
22
|
+
import { consoleLogger, createTracer, describeBus, tracePlugin } from "nanoeffects/trace";
|
|
23
|
+
|
|
24
|
+
function saveName(name) {
|
|
25
|
+
console.log("save", name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function audit(name) {
|
|
29
|
+
console.log("audit", name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const trace = createTracer(consoleLogger());
|
|
33
|
+
|
|
34
|
+
const bus = createBus({ trace })
|
|
35
|
+
.use(tracePlugin());
|
|
36
|
+
|
|
37
|
+
bus.on("name/changed", saveName);
|
|
38
|
+
bus.on("name/changed", audit);
|
|
39
|
+
|
|
40
|
+
// describeBus: the listeners of each event, in the order they run
|
|
41
|
+
console.log(describeBus(bus));
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
{ "name/changed": ["saveName", "audit"] }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Details: [List who listens](tracing.md#list-who-listens).
|
|
49
|
+
|
|
50
|
+
## Find which task began a chain
|
|
51
|
+
|
|
52
|
+
**Problem.** A request ran and you do not know what asked for it.
|
|
53
|
+
|
|
54
|
+
Every task of a trace has a `rootId` and a `parentId`. The recorder keeps them, so you can walk up from the request to the emit that began it:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { createBus } from "nanoeffects";
|
|
58
|
+
import { createTraceRecorder, createTracer, tracePlugin } from "nanoeffects/trace";
|
|
59
|
+
|
|
60
|
+
// createTraceRecorder: keeps the last events in memory
|
|
61
|
+
const recorder = createTraceRecorder();
|
|
62
|
+
const trace = createTracer(recorder);
|
|
63
|
+
|
|
64
|
+
const bus = createBus({ trace })
|
|
65
|
+
.use(tracePlugin());
|
|
66
|
+
|
|
67
|
+
function loadUser(id, ctx) {
|
|
68
|
+
ctx.emit("user/loading", id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
bus.on("user/open", loadUser);
|
|
72
|
+
bus.emit("user/open", 7);
|
|
73
|
+
|
|
74
|
+
// each event says which task is its parent
|
|
75
|
+
for (const event of recorder.events) {
|
|
76
|
+
console.log(event.id, event.parentId, event.type, event.phase);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Details: [Show which subscription began a chain](tracing.md#show-which-subscription-began-a-chain) and [Keep the chain: `ctx.emit`, not `bus.emit`](tracing.md#keep-the-chain-ctxemit-not-busemit).
|
|
81
|
+
|
|
82
|
+
## Find out why a request stopped
|
|
83
|
+
|
|
84
|
+
**Problem.** A search request never finished and no error appeared.
|
|
85
|
+
|
|
86
|
+
A policy that stops a call reports `cancel` or `drop` in the trace. Pick the call and read its last phase:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { createBus } from "nanoeffects";
|
|
90
|
+
import { latest } from "nanoeffects/policies";
|
|
91
|
+
import { createTraceRecorder, createTracer, tracePlugin } from "nanoeffects/trace";
|
|
92
|
+
|
|
93
|
+
const recorder = createTraceRecorder();
|
|
94
|
+
const trace = createTracer(recorder);
|
|
95
|
+
|
|
96
|
+
const bus = createBus({ trace })
|
|
97
|
+
.use(tracePlugin());
|
|
98
|
+
|
|
99
|
+
// latest: a new search stops the one that is still running
|
|
100
|
+
const search = latest(async (query, ctx) => {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
102
|
+
ctx.emit("search/loaded", query);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
bus.on("search/changed", search, { traceId: "search" });
|
|
106
|
+
bus.emit("search/changed", "a");
|
|
107
|
+
bus.emit("search/changed", "ab");
|
|
108
|
+
|
|
109
|
+
// a "cancel" phase on the first call says that the second one stopped it
|
|
110
|
+
const phases = recorder.events.map((event) => `${event.type}:${event.phase}`);
|
|
111
|
+
console.log(phases);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Details: [Trace tasks](tracing.md) and [Cancel stale requests](cancel-stale-requests.md).
|
|
115
|
+
|
|
116
|
+
## Know what is missing
|
|
117
|
+
|
|
118
|
+
- **No browser extension or UI of its own.** Use [Redux DevTools](tracing.md#configure-redux-devtools), the console or the Performance panel.
|
|
119
|
+
- **No map from the source code.** `describeBus` lists the listeners registered so far, and `graphLogger` shows the events that happened. A listener that is not registered yet is not on the map.
|
|
120
|
+
- **No click-through from the log to the code.** With `tracePlugin({ source: true })` an event carries the `file:line:column` of its listener ([Find the line of a listener](tracing.md#find-the-line-of-a-listener)), but a link in a UI is yours to build.
|
|
121
|
+
- **No names from a compiler.** Function names give a name for free; a minifier can shorten them, so use `traceId` where a name must stay stable.
|
|
122
|
+
- **No replay and no time travel.** The recorder keeps what happened and cannot run it again.
|
package/docs/debugging-loops.md
CHANGED
|
@@ -16,6 +16,7 @@ A loop is two handlers that emit each other's events, or one handler that emits
|
|
|
16
16
|
- [Cut a loop at run time](#cut-a-loop-at-run-time)
|
|
17
17
|
- [Fail a test on a loop](#fail-a-test-on-a-loop)
|
|
18
18
|
- [Export the graph and the payloads](#export-the-graph-and-the-payloads)
|
|
19
|
+
- [Find listeners that never ran](#find-listeners-that-never-ran)
|
|
19
20
|
- [Know how a loop is found](#know-how-a-loop-is-found)
|
|
20
21
|
- [Know the limits](#know-the-limits)
|
|
21
22
|
|
|
@@ -278,6 +279,84 @@ console.log(JSON.stringify(data));
|
|
|
278
279
|
|
|
279
280
|
A payload can be a class instance, a `Map`, a circular object or an `Error`, which a plain `JSON.stringify` cannot take. The exports make them plain JSON: an `Error` keeps its name, message, stack and cause, a cycle becomes `"[Circular]"`, a `Map` becomes `{ "$map": [...] }`, and an instance of a class keeps its own fields and its name as `$class`. Getters are never called, and a value that is too deep or too big is cut.
|
|
280
281
|
|
|
282
|
+
## Find listeners that never ran
|
|
283
|
+
|
|
284
|
+
A graph of one run shows what that run did. It cannot tell you which listeners the app has but no run ever reached: dead code, or a scenario nobody tests. To find them, join the graphs of several runs, then compare the result with the listeners the bus holds.
|
|
285
|
+
|
|
286
|
+
Prerequisites: the bus uses `tracePlugin` and `graphLogger` (see [Find a loop](#find-a-loop)), and each listener has a function name or a `traceId`. An anonymous listener has no node in the graph, so it is never reported.
|
|
287
|
+
|
|
288
|
+
1. Each run writes its graph to a file.
|
|
289
|
+
2. A script reads the files and joins them with `mergeGraphs`.
|
|
290
|
+
3. `findOrphans` compares the joined graph with `describeBus`.
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
// bus.js
|
|
294
|
+
import { createBus } from "nanoeffects";
|
|
295
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
296
|
+
|
|
297
|
+
// graphLogger — the map of what ran
|
|
298
|
+
export const graph = graphLogger({ name: "cart" });
|
|
299
|
+
export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
// handlers.js
|
|
304
|
+
import { bus } from "./bus";
|
|
305
|
+
|
|
306
|
+
// the listener is named by its function; findOrphans matches this name
|
|
307
|
+
bus.on("cart/added", function recalcTotal() {});
|
|
308
|
+
bus.on("cart/cleared", function resetTotal() {});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// graph-setup.js (the setup file of your test runner)
|
|
313
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
314
|
+
import { afterAll } from "vitest";
|
|
315
|
+
import { graph } from "./bus";
|
|
316
|
+
|
|
317
|
+
afterAll(() => {
|
|
318
|
+
mkdirSync("graphs", { recursive: true });
|
|
319
|
+
|
|
320
|
+
// toJSON — the graph of this run as plain data
|
|
321
|
+
const data = graph.toJSON();
|
|
322
|
+
writeFileSync(`graphs/${process.pid}.json`, JSON.stringify(data));
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
// orphans.js (run it after the tests)
|
|
328
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
329
|
+
import { describeBus, findOrphans, mergeGraphs } from "nanoeffects/trace";
|
|
330
|
+
import { bus } from "./bus";
|
|
331
|
+
import "./handlers";
|
|
332
|
+
|
|
333
|
+
const files = readdirSync("graphs");
|
|
334
|
+
const runs = files.map((file) => JSON.parse(readFileSync(`graphs/${file}`, "utf8")));
|
|
335
|
+
|
|
336
|
+
// mergeGraphs — counts add up, so a listener that ran in any run is a node
|
|
337
|
+
const all = mergeGraphs(...runs);
|
|
338
|
+
|
|
339
|
+
// describeBus — the listeners the bus holds, by event
|
|
340
|
+
const described = describeBus(bus);
|
|
341
|
+
|
|
342
|
+
// findOrphans — the listeners the joined graph never saw run
|
|
343
|
+
console.log(findOrphans(described, all));
|
|
344
|
+
// [{ event: "cart/cleared", listener: "resetTotal", kind: "unemitted" }]
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Each item has a `kind`:
|
|
348
|
+
|
|
349
|
+
| `kind` | Meaning | What to check |
|
|
350
|
+
| ------ | ------- | ------------- |
|
|
351
|
+
| `unemitted` | Nothing emitted the event in any run. | Is the event still used? Is a scenario missing? |
|
|
352
|
+
| `unrun` | The event was emitted, this listener did not run. | A condition or a [policy](policies.md) that never lets it run. |
|
|
353
|
+
|
|
354
|
+
Read the list as a hint, not as a failure. With few runs it holds many false items, and it only becomes a signal when the runs cover the main scenarios. Know the limits:
|
|
355
|
+
|
|
356
|
+
- Two listeners with one name are one node, so the second one is never reported. Give each a distinct name or `traceId`.
|
|
357
|
+
- Merge runs of one build. A listener that was renamed shows up as an orphan.
|
|
358
|
+
- `mergeGraphs` does not find a loop that only closes when the edges of two runs are joined. Use `graph.cycles()` in each run.
|
|
359
|
+
|
|
281
360
|
## Know how a loop is found
|
|
282
361
|
|
|
283
362
|
`graphLogger` finds loops in two ways. They give two kinds of finding in `graph.cycles()`, told apart by `source`:
|
|
@@ -194,4 +194,4 @@ The subscription is not tied to a task. Call `subscription.unsubscribe()` when t
|
|
|
194
194
|
- **Abort replaces unsubscribe for work in flight.** A stopped task is stopped through its `signal`. Pass it to `fetch` or to the client.
|
|
195
195
|
- **Errors are not part of the stream.** A failed task rejects its own promise. Without `onError` on the bus nobody else hears it ([Errors](errors.md)).
|
|
196
196
|
- **No completion.** An event bus does not end. A task ends when its work ends or its signal aborts.
|
|
197
|
-
- **
|
|
197
|
+
- **Marble tests are a smaller tool.** [`createMarbleBus`](testing.md) writes the emits and the phases of tasks as diagrams on fake timers. It has no hot or cold sources and no `|`.
|
package/docs/package.md
CHANGED
|
@@ -30,7 +30,8 @@ Each row is optional. The "when" column says what need brings you to it.
|
|
|
30
30
|
| `nanoeffects/svelte` | `sveltePlugin`: adds `eventStore` to a bus (needs Svelte 5 or newer) | Svelte components |
|
|
31
31
|
| `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) | Vue components |
|
|
32
32
|
| `nanoeffects/dev` | `devPlugin` (no-op in production), `getLostSignalsCount` and `getLostSignals` for tests | you look for leaks and lost signals in development and in tests |
|
|
33
|
-
| `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops) and `tracePlugin` (names for the trace) | you want to see what ran and why |
|
|
33
|
+
| `nanoeffects/trace` | `createTracer`, the loggers (`graphLogger` finds event loops; `mergeGraphs` and `findOrphans` find listeners no run reached) and `tracePlugin` (names for the trace) | you want to see what ran and why |
|
|
34
|
+
| `nanoeffects/testing` | `createMarbleBus`, `fakeTimersClock`: a bus for tests, with emits and phases as diagrams | you test `latest`, `debounce`, `queue`, `retry` or `timeout` with fake timers |
|
|
34
35
|
| `nanoeffects/guard` | `loopGuardPlugin`: refuses the emit that would pass a limit of a chain | a loop must not take the app down |
|
|
35
36
|
|
|
36
37
|
The `...Plugin` names above are used with `bus.use`. [Plugins](plugins.md) lists all of them, what each returns, and the order to use them in.
|
package/docs/recipes.md
CHANGED
|
@@ -29,6 +29,7 @@ Each task below starts with the problem, then a short solution. Where the page h
|
|
|
29
29
|
- [Do not leave subscriptions after a widget closes](#do-not-leave-subscriptions-after-a-widget-closes)
|
|
30
30
|
- [Stop work on a server](#stop-work-on-a-server)
|
|
31
31
|
- [Debugging: what ran and why](#debugging-what-ran-and-why)
|
|
32
|
+
- [Test a policy with fake timers](#test-a-policy-with-fake-timers)
|
|
32
33
|
- [Other libraries in the project](#other-libraries-in-the-project)
|
|
33
34
|
- [Classes and TypeScript](#classes-and-typescript)
|
|
34
35
|
- [Use the bus in a class](#use-the-bus-in-a-class)
|
|
@@ -480,6 +481,40 @@ bus.emit("search", "nano"); // the log shows: search · emit, then results · em
|
|
|
480
481
|
|
|
481
482
|
More: [Trace tasks](tracing.md). To reload modules without a page refresh, see [Hot reload](hmr.md).
|
|
482
483
|
|
|
484
|
+
## Test a policy with fake timers
|
|
485
|
+
|
|
486
|
+
A test of `latest` or `debounce` is full of `advanceTimersByTime` numbers, and nobody can tell from them what the test proves. Write the emits and the phases as diagrams instead.
|
|
487
|
+
|
|
488
|
+
```js
|
|
489
|
+
import { it, vi } from "vitest";
|
|
490
|
+
import { latest } from "nanoeffects/policies";
|
|
491
|
+
import { createMarbleBus, fakeTimersClock } from "nanoeffects/testing";
|
|
492
|
+
|
|
493
|
+
// a search that takes 50 ms
|
|
494
|
+
function search(query) {
|
|
495
|
+
return new Promise((resolve) => setTimeout(resolve, 50));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
it("latest cancels the search that is running", async () => {
|
|
499
|
+
vi.useFakeTimers();
|
|
500
|
+
|
|
501
|
+
// createMarbleBus: a bus that records its trace; a frame is 10 ms
|
|
502
|
+
const marbles = createMarbleBus({ clock: fakeTimersClock(vi) });
|
|
503
|
+
marbles.bus.on("search/changed", latest(search), { traceId: "search" });
|
|
504
|
+
|
|
505
|
+
// "a-b": two emits, at frame 0 and frame 2
|
|
506
|
+
const result = await marbles.run({
|
|
507
|
+
emits: { "search/changed": { marble: "a-b" } },
|
|
508
|
+
frames: 12,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// s start, c cancel, d done
|
|
512
|
+
result.expect({ "search/changed:latest": "s-(cs)----d----" });
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
More: [Test time with diagrams](testing.md).
|
|
517
|
+
|
|
483
518
|
## Other libraries in the project
|
|
484
519
|
|
|
485
520
|
- **Redux Toolkit, Zustand, MobX or Pinia.** [Connect a store](stores.md).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Случаи отладки
|
|
2
|
+
|
|
3
|
+
> **[Уровень F](README.md#виды-проектов)**: платформа, SDK или долгоживущее приложение. Сначала прочитайте: [Трассировка задач](tracing.md#подключение-трассировщика).
|
|
4
|
+
|
|
5
|
+
Прочитайте эту страницу, когда что-то идёт не так и нужно понять, кто слушает событие, что начало цепочку или почему остановился запрос. Каждый случай ниже — короткий пример, который можно запустить. О циклах читайте в разделе [Поиск и обрыв циклов событий](debugging-loops.md).
|
|
6
|
+
|
|
7
|
+
## На этой странице
|
|
8
|
+
|
|
9
|
+
- [Кто слушает событие](#кто-слушает-событие)
|
|
10
|
+
- [Какая задача начала цепочку](#какая-задача-начала-цепочку)
|
|
11
|
+
- [Почему запрос остановился](#почему-запрос-остановился)
|
|
12
|
+
- [Чего не хватает](#чего-не-хватает)
|
|
13
|
+
|
|
14
|
+
## Кто слушает событие
|
|
15
|
+
|
|
16
|
+
**Проблема.** Вы читаете emit и хотите знать, что он запускает.
|
|
17
|
+
|
|
18
|
+
Назовите обработчики функциями или через `traceId`, подключите `tracePlugin` и вызовите `describeBus`:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { createBus } from "nanoeffects";
|
|
22
|
+
import { consoleLogger, createTracer, describeBus, tracePlugin } from "nanoeffects/trace";
|
|
23
|
+
|
|
24
|
+
function saveName(name) {
|
|
25
|
+
console.log("save", name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function audit(name) {
|
|
29
|
+
console.log("audit", name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const trace = createTracer(consoleLogger());
|
|
33
|
+
|
|
34
|
+
const bus = createBus({ trace })
|
|
35
|
+
.use(tracePlugin());
|
|
36
|
+
|
|
37
|
+
bus.on("name/changed", saveName);
|
|
38
|
+
bus.on("name/changed", audit);
|
|
39
|
+
|
|
40
|
+
// describeBus: обработчики каждого события в порядке запуска
|
|
41
|
+
console.log(describeBus(bus));
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
{ "name/changed": ["saveName", "audit"] }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Подробнее: [Список подписчиков события](tracing.md#список-подписчиков-события).
|
|
49
|
+
|
|
50
|
+
## Какая задача начала цепочку
|
|
51
|
+
|
|
52
|
+
**Проблема.** Запрос выполнился, а вы не знаете, что его запросило.
|
|
53
|
+
|
|
54
|
+
У каждой задачи трассировки есть `rootId` и `parentId`. Recorder хранит их, поэтому от запроса можно подняться к emit, который начал цепочку:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { createBus } from "nanoeffects";
|
|
58
|
+
import { createTraceRecorder, createTracer, tracePlugin } from "nanoeffects/trace";
|
|
59
|
+
|
|
60
|
+
// createTraceRecorder: хранит последние события в памяти
|
|
61
|
+
const recorder = createTraceRecorder();
|
|
62
|
+
const trace = createTracer(recorder);
|
|
63
|
+
|
|
64
|
+
const bus = createBus({ trace })
|
|
65
|
+
.use(tracePlugin());
|
|
66
|
+
|
|
67
|
+
function loadUser(id, ctx) {
|
|
68
|
+
ctx.emit("user/loading", id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
bus.on("user/open", loadUser);
|
|
72
|
+
bus.emit("user/open", 7);
|
|
73
|
+
|
|
74
|
+
// каждое событие говорит, какая задача его родитель
|
|
75
|
+
for (const event of recorder.events) {
|
|
76
|
+
console.log(event.id, event.parentId, event.type, event.phase);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Подробнее: [Какая подписка начала цепочку](tracing.md#какая-подписка-начала-цепочку) и [Сохранение цепочки: `ctx.emit` вместо `bus.emit`](tracing.md#сохранение-цепочки-ctxemit-вместо-busemit).
|
|
81
|
+
|
|
82
|
+
## Почему запрос остановился
|
|
83
|
+
|
|
84
|
+
**Проблема.** Запрос поиска не завершился, а ошибки нет.
|
|
85
|
+
|
|
86
|
+
Политика, которая останавливает вызов, пишет в трассировку `cancel` или `drop`. Найдите вызов и прочитайте его последнюю фазу:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { createBus } from "nanoeffects";
|
|
90
|
+
import { latest } from "nanoeffects/policies";
|
|
91
|
+
import { createTraceRecorder, createTracer, tracePlugin } from "nanoeffects/trace";
|
|
92
|
+
|
|
93
|
+
const recorder = createTraceRecorder();
|
|
94
|
+
const trace = createTracer(recorder);
|
|
95
|
+
|
|
96
|
+
const bus = createBus({ trace })
|
|
97
|
+
.use(tracePlugin());
|
|
98
|
+
|
|
99
|
+
// latest: новый поиск останавливает тот, что ещё выполняется
|
|
100
|
+
const search = latest(async (query, ctx) => {
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
102
|
+
ctx.emit("search/loaded", query);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
bus.on("search/changed", search, { traceId: "search" });
|
|
106
|
+
bus.emit("search/changed", "a");
|
|
107
|
+
bus.emit("search/changed", "ab");
|
|
108
|
+
|
|
109
|
+
// фаза "cancel" у первого вызова говорит, что его остановил второй
|
|
110
|
+
const phases = recorder.events.map((event) => `${event.type}:${event.phase}`);
|
|
111
|
+
console.log(phases);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Подробнее: [Трассировка задач](tracing.md) и [Отмена устаревших запросов](cancel-stale-requests.md).
|
|
115
|
+
|
|
116
|
+
## Чего не хватает
|
|
117
|
+
|
|
118
|
+
- **Нет расширения для браузера и своего интерфейса.** Используйте [Redux DevTools](tracing.md#настройка-redux-devtools), консоль или панель Performance.
|
|
119
|
+
- **Нет карты по исходному коду.** `describeBus` показывает обработчики, зарегистрированные к этому моменту, а `graphLogger` — события, которые произошли. Обработчик, который ещё не зарегистрирован, на карте не появится.
|
|
120
|
+
- **Нет перехода из лога в код.** С `tracePlugin({ source: true })` событие несёт `file:line:column` своего обработчика ([Строка кода обработчика](tracing.md#строка-кода-обработчика)), но ссылку в интерфейсе вы делаете сами.
|
|
121
|
+
- **Нет имён от компилятора.** Имена функций дают имя бесплатно; минификатор может их сократить, поэтому используйте `traceId`, где имя должно быть стабильным.
|
|
122
|
+
- **Нет повтора и путешествий во времени.** Recorder хранит то, что случилось, и не умеет запускать это заново.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
- [Обрыв цикла во время работы](#обрыв-цикла-во-время-работы)
|
|
17
17
|
- [Падение теста при цикле](#падение-теста-при-цикле)
|
|
18
18
|
- [Экспорт графа и payload](#экспорт-графа-и-payload)
|
|
19
|
+
- [Подписки, которые не запускались](#подписки-которые-не-запускались)
|
|
19
20
|
- [Как находится цикл](#как-находится-цикл)
|
|
20
21
|
- [Ограничения](#ограничения)
|
|
21
22
|
|
|
@@ -278,6 +279,84 @@ console.log(JSON.stringify(data));
|
|
|
278
279
|
|
|
279
280
|
Payload может быть экземпляром класса, `Map`, объектом с циклической ссылкой или `Error`, которые обычный `JSON.stringify` не примет. Экспорт превращает их в обычный JSON: у `Error` остаются имя, сообщение, стек и причина, цикл становится `"[Circular]"`, `Map` — `{ "$map": [...] }`, а экземпляр класса сохраняет собственные поля и имя класса в `$class`. Геттеры не вызываются, а слишком глубокое или слишком большое значение обрезается.
|
|
280
281
|
|
|
282
|
+
## Подписки, которые не запускались
|
|
283
|
+
|
|
284
|
+
Граф одного запуска показывает только то, что сделал этот запуск. Он не скажет, какие подписки есть в приложении, но ни один запуск до них не дошёл: мёртвый код или сценарий, который никто не тестирует. Чтобы их найти, объедините графы нескольких запусков и сравните результат с подписками шины.
|
|
285
|
+
|
|
286
|
+
Что нужно заранее: шина использует `tracePlugin` и `graphLogger` (см. [Поиск цикла](#поиск-цикла)), а у каждой подписки есть имя функции или `traceId`. У анонимной подписки нет узла в графе, поэтому о ней не сообщается.
|
|
287
|
+
|
|
288
|
+
1. Каждый запуск пишет свой граф в файл.
|
|
289
|
+
2. Скрипт читает файлы и объединяет их через `mergeGraphs`.
|
|
290
|
+
3. `findOrphans` сравнивает объединённый граф с `describeBus`.
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
// bus.js
|
|
294
|
+
import { createBus } from "nanoeffects";
|
|
295
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
296
|
+
|
|
297
|
+
// graphLogger — карта того, что выполнилось
|
|
298
|
+
export const graph = graphLogger({ name: "cart" });
|
|
299
|
+
export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
// handlers.js
|
|
304
|
+
import { bus } from "./bus";
|
|
305
|
+
|
|
306
|
+
// подписка названа по функции; findOrphans сопоставляет это имя
|
|
307
|
+
bus.on("cart/added", function recalcTotal() {});
|
|
308
|
+
bus.on("cart/cleared", function resetTotal() {});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// graph-setup.js (setup-файл вашего тест-раннера)
|
|
313
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
314
|
+
import { afterAll } from "vitest";
|
|
315
|
+
import { graph } from "./bus";
|
|
316
|
+
|
|
317
|
+
afterAll(() => {
|
|
318
|
+
mkdirSync("graphs", { recursive: true });
|
|
319
|
+
|
|
320
|
+
// toJSON — граф этого запуска как обычные данные
|
|
321
|
+
const data = graph.toJSON();
|
|
322
|
+
writeFileSync(`graphs/${process.pid}.json`, JSON.stringify(data));
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
// orphans.js (запустите после тестов)
|
|
328
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
329
|
+
import { describeBus, findOrphans, mergeGraphs } from "nanoeffects/trace";
|
|
330
|
+
import { bus } from "./bus";
|
|
331
|
+
import "./handlers";
|
|
332
|
+
|
|
333
|
+
const files = readdirSync("graphs");
|
|
334
|
+
const runs = files.map((file) => JSON.parse(readFileSync(`graphs/${file}`, "utf8")));
|
|
335
|
+
|
|
336
|
+
// mergeGraphs — счётчики складываются, поэтому подписка, выполнявшаяся в любом запуске, становится узлом
|
|
337
|
+
const all = mergeGraphs(...runs);
|
|
338
|
+
|
|
339
|
+
// describeBus — подписки, которые держит шина, по событиям
|
|
340
|
+
const described = describeBus(bus);
|
|
341
|
+
|
|
342
|
+
// findOrphans — подписки, которые объединённый граф ни разу не видел
|
|
343
|
+
console.log(findOrphans(described, all));
|
|
344
|
+
// [{ event: "cart/cleared", listener: "resetTotal", kind: "unemitted" }]
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
У каждого элемента есть `kind`:
|
|
348
|
+
|
|
349
|
+
| `kind` | Что значит | Что проверить |
|
|
350
|
+
| ------ | ---------- | ------------- |
|
|
351
|
+
| `unemitted` | Ни в одном запуске никто не отправил это событие. | Нужно ли ещё событие? Не хватает ли сценария? |
|
|
352
|
+
| `unrun` | Событие отправлялось, а эта подписка не запускалась. | Условие или [политика](policies.md), из-за которых она не запускается. |
|
|
353
|
+
|
|
354
|
+
Читайте список как подсказку, а не как ошибку. При малом числе запусков в нём много ложных записей, и сигналом он становится, когда запуски покрывают основные сценарии. Ограничения:
|
|
355
|
+
|
|
356
|
+
- Две подписки с одним именем дают один узел, поэтому вторая никогда не попадёт в список. Дайте каждой своё имя или `traceId`.
|
|
357
|
+
- Объединяйте запуски одной сборки. Переименованная подписка попадёт в список.
|
|
358
|
+
- `mergeGraphs` не находит цикл, который замыкается только при объединении рёбер разных запусков. Смотрите `graph.cycles()` в каждом запуске.
|
|
359
|
+
|
|
281
360
|
## Как находится цикл
|
|
282
361
|
|
|
283
362
|
`graphLogger` находит циклы двумя способами. Они дают два вида находок в `graph.cycles()`, которые различает поле `source`:
|
|
@@ -194,4 +194,4 @@ bus.on(
|
|
|
194
194
|
- **Прерывание заменяет отписку для работы в полёте.** Остановленная задача останавливается через её `signal`. Передайте его в `fetch` или в клиент.
|
|
195
195
|
- **Ошибки не часть потока.** Упавшая задача отклоняет собственный Promise. Без `onError` на шине об ошибке никто больше не узнает ([Ошибки](errors.md)).
|
|
196
196
|
- **Нет завершения.** Шина событий не заканчивается. Задача заканчивается, когда кончается её работа или прерывается её сигнал.
|
|
197
|
-
-
|
|
197
|
+
- **Marble-тесты — инструмент поменьше.** [`createMarbleBus`](testing.md) записывает отправки и фазы задач диаграммами на поддельных таймерах. В нём нет горячих и холодных источников и нет `|`.
|
package/docs/ru/package.md
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
| `nanoeffects/svelte` | `sveltePlugin`: добавляет `eventStore` на шину (нужен Svelte 5 или новее) | компоненты Svelte |
|
|
27
27
|
| `nanoeffects/vue` | `vuePlugin`: добавляет `watchEmitter` и `useEventRef` на шину (нужен Vue 3.3 или новее) | компоненты Vue |
|
|
28
28
|
| `nanoeffects/dev` | `devPlugin` (в production ничего не делает), `getLostSignalsCount` и `getLostSignals` для тестов | нужно находить утечки подписок и потерянные сигналы при разработке и в тестах |
|
|
29
|
-
| `nanoeffects/trace` | `createTracer`, логгеры (`graphLogger` находит циклы
|
|
29
|
+
| `nanoeffects/trace` | `createTracer`, логгеры (`graphLogger` находит циклы событий; `mergeGraphs` и `findOrphans` находят подписки, до которых не дошёл ни один запуск) и `tracePlugin` (имена для трассировки) | нужно видеть, что выполнилось и почему |
|
|
30
|
+
| `nanoeffects/testing` | `createMarbleBus`, `fakeTimersClock`: шина для тестов, где отправки и фазы записаны диаграммами | вы тестируете `latest`, `debounce`, `queue`, `retry` или `timeout` на поддельных таймерах |
|
|
30
31
|
| `nanoeffects/guard` | `loopGuardPlugin`: отклоняет emit, который превысил бы предел цепочки | цикл не должен ронять приложение |
|
|
31
32
|
|
|
32
33
|
Плагины с именами `...Plugin` подключаются через `bus.use`. Страница [Плагины](plugins.md) перечисляет их все, говорит, что каждый возвращает и в каком порядке их подключать.
|
package/docs/ru/recipes.md
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
- [Не оставлять подписки после закрытия виджета](#не-оставлять-подписки-после-закрытия-виджета)
|
|
30
30
|
- [Остановка работы на backend](#остановка-работы-на-backend)
|
|
31
31
|
- [Отладка: что выполнилось и почему](#отладка-что-выполнилось-и-почему)
|
|
32
|
+
- [Тест политики на поддельных таймерах](#тест-политики-на-поддельных-таймерах)
|
|
32
33
|
- [Другие библиотеки в проекте](#другие-библиотеки-в-проекте)
|
|
33
34
|
- [ООП и TypeScript](#ооп-и-typescript)
|
|
34
35
|
- [Шина в классе](#шина-в-классе)
|
|
@@ -480,6 +481,40 @@ bus.emit("search", "nano"); // в логе: search · emit, затем results
|
|
|
480
481
|
|
|
481
482
|
Подробнее: [Трассировка задач](tracing.md). Чтобы перезагружать модули без обновления страницы, см. [Hot reload](hmr.md).
|
|
482
483
|
|
|
484
|
+
## Тест политики на поддельных таймерах
|
|
485
|
+
|
|
486
|
+
Тест `latest` или `debounce` полон чисел `advanceTimersByTime`, и по ним никто не поймёт, что тест доказывает. Запишите отправки и фазы диаграммами.
|
|
487
|
+
|
|
488
|
+
```js
|
|
489
|
+
import { it, vi } from "vitest";
|
|
490
|
+
import { latest } from "nanoeffects/policies";
|
|
491
|
+
import { createMarbleBus, fakeTimersClock } from "nanoeffects/testing";
|
|
492
|
+
|
|
493
|
+
// поиск, который занимает 50 мс
|
|
494
|
+
function search(query) {
|
|
495
|
+
return new Promise((resolve) => setTimeout(resolve, 50));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
it("latest cancels the search that is running", async () => {
|
|
499
|
+
vi.useFakeTimers();
|
|
500
|
+
|
|
501
|
+
// createMarbleBus: шина, которая записывает трассировку; кадр — 10 мс
|
|
502
|
+
const marbles = createMarbleBus({ clock: fakeTimersClock(vi) });
|
|
503
|
+
marbles.bus.on("search/changed", latest(search), { traceId: "search" });
|
|
504
|
+
|
|
505
|
+
// "a-b": две отправки, в кадре 0 и в кадре 2
|
|
506
|
+
const result = await marbles.run({
|
|
507
|
+
emits: { "search/changed": { marble: "a-b" } },
|
|
508
|
+
frames: 12,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// s старт, c отмена, d готово
|
|
512
|
+
result.expect({ "search/changed:latest": "s-(cs)----d----" });
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Подробнее: [Тесты времени диаграммами](testing.md).
|
|
517
|
+
|
|
483
518
|
## Другие библиотеки в проекте
|
|
484
519
|
|
|
485
520
|
- **Redux Toolkit, Zustand, MobX или Pinia.** [Подключение store](stores.md).
|