nanoeffects 0.28.0 → 0.30.1

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 (139) hide show
  1. package/README.md +6 -5
  2. package/dist/{action-creator-CcAEvUnw.d.ts → action-creator-6rlBTmH9.d.ts} +60 -33
  3. package/dist/actions.d.ts +18 -18
  4. package/dist/actions.js +1 -1
  5. package/dist/bus-DFlmNJ6h.js +1 -0
  6. package/dist/context-B5Z2RpJt.js +1 -0
  7. package/dist/context-types-DKNc5mkS.d.ts +31 -0
  8. package/dist/context.d.ts +8 -3
  9. package/dist/context.js +1 -1
  10. package/dist/{delay-18LJ8jSk.js → delay-5OhJZZQ0.js} +1 -1
  11. package/dist/dev.d.ts +44 -3
  12. package/dist/dev.js +2 -1
  13. package/dist/effects.d.ts +2 -2
  14. package/dist/effects.js +1 -1
  15. package/dist/emit-key-C6FL7Ewn.js +1 -0
  16. package/dist/guard.d.ts +59 -0
  17. package/dist/guard.js +1 -0
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +1 -1
  20. package/dist/match.d.ts +3 -3
  21. package/dist/match.js +1 -1
  22. package/dist/{module-C_oEXw1n.d.ts → module-G39MaVOt.d.ts} +38 -38
  23. package/dist/notrace/{action-creator-DxOvVo8I.d.ts → action-creator-BrMSO3RP.d.ts} +35 -32
  24. package/dist/notrace/actions.d.ts +52 -52
  25. package/dist/notrace/actions.js +1 -1
  26. package/dist/notrace/{bus-DmKn4SDD.js → bus-37nlVljc.js} +1 -1
  27. package/dist/notrace/effects.d.ts +2 -2
  28. package/dist/notrace/index.d.ts +2 -2
  29. package/dist/notrace/index.js +1 -1
  30. package/dist/notrace/{plugin-lJjjd-SN.d.ts → plugin-7GUL2EW5.d.ts} +1 -1
  31. package/dist/notrace/policies.d.ts +1 -1
  32. package/dist/notrace/redux.d.ts +1 -1
  33. package/dist/observable.d.ts +43 -1
  34. package/dist/observable.js +1 -1
  35. package/dist/onAbort-BgMfspK4.js +1 -0
  36. package/dist/oop.d.ts +3 -3
  37. package/dist/oop.js +1 -1
  38. package/dist/{plugin-CuvIgjnX.d.ts → plugin-BWNP23hr.d.ts} +2 -2
  39. package/dist/policies.d.ts +2 -2
  40. package/dist/policies.js +1 -1
  41. package/dist/production/dev.js +1 -1
  42. package/dist/react.d.ts +2 -2
  43. package/dist/react.js +1 -1
  44. package/dist/redux.d.ts +1 -1
  45. package/dist/scope.d.ts +9 -9
  46. package/dist/signal-field-key-CWT-3OXC.js +1 -0
  47. package/dist/signal.d.ts +34 -6
  48. package/dist/signal.js +1 -1
  49. package/dist/site-DLvUoXs3.js +1 -0
  50. package/dist/svelte.d.ts +1 -1
  51. package/dist/trace.d.ts +170 -6
  52. package/dist/trace.js +3 -1
  53. package/dist/{types-FV8fuvI9.d.ts → types-N_cdRr1r.d.ts} +2 -2
  54. package/dist/vue.d.ts +1 -1
  55. package/dist/{with-policy-D-SQRkPh.js → with-policy-CeM1rtES.js} +1 -1
  56. package/dist/{with-policy-3bhFLEXs.d.ts → with-policy-CthiBp_z.d.ts} +1 -1
  57. package/docs/README.md +65 -22
  58. package/docs/actions.md +56 -13
  59. package/docs/cancel-stale-requests.md +2 -0
  60. package/docs/chat-example.md +15 -1
  61. package/docs/chat-widget.md +9 -7
  62. package/docs/context.md +352 -8
  63. package/docs/debounce-and-abort.md +3 -1
  64. package/docs/debugging-loops.md +283 -0
  65. package/docs/effects.md +259 -131
  66. package/docs/errors.md +4 -2
  67. package/docs/examples.md +60 -0
  68. package/docs/guide.md +57 -22
  69. package/docs/hmr.md +35 -1
  70. package/docs/loop-guard.md +109 -0
  71. package/docs/migrating-from-effector.md +15 -2
  72. package/docs/migrating-from-mitt.md +6 -4
  73. package/docs/migrating-from-rxjs.md +2 -0
  74. package/docs/migrating-from-saga.md +2 -0
  75. package/docs/motivation.md +14 -21
  76. package/docs/observable.md +45 -0
  77. package/docs/oop.md +20 -1
  78. package/docs/package.md +25 -13
  79. package/docs/pass-the-signal.md +281 -0
  80. package/docs/plugins.md +90 -15
  81. package/docs/policies.md +73 -46
  82. package/docs/prevent-double-submit.md +3 -1
  83. package/docs/query-libraries.md +276 -6
  84. package/docs/react.md +50 -11
  85. package/docs/recipes.md +528 -0
  86. package/docs/ru/README.md +62 -0
  87. package/docs/ru/actions.md +412 -0
  88. package/docs/ru/cancel-stale-requests.md +106 -0
  89. package/docs/ru/chat-example.md +259 -0
  90. package/docs/ru/chat-widget.md +131 -0
  91. package/docs/ru/context.md +401 -0
  92. package/docs/ru/debounce-and-abort.md +105 -0
  93. package/docs/ru/debugging-loops.md +283 -0
  94. package/docs/ru/effects.md +538 -0
  95. package/docs/ru/errors.md +36 -0
  96. package/docs/ru/examples.md +60 -0
  97. package/docs/ru/guide.md +530 -0
  98. package/docs/ru/hmr.md +83 -0
  99. package/docs/ru/loop-guard.md +109 -0
  100. package/docs/ru/migrating-from-effector.md +339 -0
  101. package/docs/ru/migrating-from-mitt.md +99 -0
  102. package/docs/ru/migrating-from-rxjs.md +197 -0
  103. package/docs/ru/migrating-from-saga.md +97 -0
  104. package/docs/ru/motivation.md +47 -0
  105. package/docs/ru/observable.md +233 -0
  106. package/docs/ru/oop.md +431 -0
  107. package/docs/ru/package.md +42 -0
  108. package/docs/ru/pass-the-signal.md +281 -0
  109. package/docs/ru/plugins.md +398 -0
  110. package/docs/ru/policies.md +201 -0
  111. package/docs/ru/prevent-double-submit.md +122 -0
  112. package/docs/ru/query-libraries.md +392 -0
  113. package/docs/ru/react.md +309 -0
  114. package/docs/ru/recipes.md +528 -0
  115. package/docs/ru/semantics.md +19 -0
  116. package/docs/ru/server.md +204 -0
  117. package/docs/ru/starter.md +118 -0
  118. package/docs/ru/stores.md +673 -0
  119. package/docs/ru/svelte.md +78 -0
  120. package/docs/ru/tracing.md +466 -0
  121. package/docs/ru/typescript.md +170 -0
  122. package/docs/ru/vue.md +92 -0
  123. package/docs/semantics.md +12 -0
  124. package/docs/server.md +5 -3
  125. package/docs/starter.md +2 -0
  126. package/docs/stores.md +2 -0
  127. package/docs/svelte.md +3 -1
  128. package/docs/tracing.md +132 -7
  129. package/docs/typescript.md +3 -1
  130. package/docs/vue.md +3 -1
  131. package/llms-full.txt +2718 -545
  132. package/llms.txt +17 -14
  133. package/package.json +25 -8
  134. package/skills/nanoeffects/SKILL.md +2 -2
  135. package/dist/bus-DlpEluzd.js +0 -1
  136. package/dist/context-UcmrAIhP.js +0 -1
  137. package/dist/context-types-hCKgU8sC.d.ts +0 -14
  138. package/docs/scope.md +0 -266
  139. package/docs/stability.md +0 -90
@@ -0,0 +1,283 @@
1
+ # Find and cut event loops
2
+
3
+ > **Level F.** Read first: [Trace tasks](tracing.md#attach-a-tracer). To only cut a loop, go to [`loopGuardPlugin`](loop-guard.md).
4
+
5
+ A loop is two handlers that emit each other's events, or one handler that emits its own. This page finds a loop, shows it, and stops it. Every scenario below is a runnable file in [`examples/debug`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug), and a page with buttons and Redux DevTools is in [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
6
+
7
+ ## On this page
8
+
9
+ - [Start from the symptom](#start-from-the-symptom)
10
+ - [Find a loop](#find-a-loop)
11
+ - [Read a report](#read-a-report)
12
+ - [Draw the graph](#draw-the-graph)
13
+ - [See the loop in Redux DevTools](#see-the-loop-in-redux-devtools)
14
+ - [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows)
15
+ - [Break the loop](#break-the-loop)
16
+ - [Cut a loop at run time](#cut-a-loop-at-run-time)
17
+ - [Fail a test on a loop](#fail-a-test-on-a-loop)
18
+ - [Export the graph and the payloads](#export-the-graph-and-the-payloads)
19
+ - [Know the limits](#know-the-limits)
20
+
21
+ ## Start from the symptom
22
+
23
+ | What you see | Likely cause | Go to |
24
+ | ------------ | ------------ | ----- |
25
+ | The page freezes | A loop of microtasks or a synchronous loop | [Find a loop](#find-a-loop), then [Cut a loop at run time](#cut-a-loop-at-run-time) |
26
+ | A request repeats on its own | A handler emits the event that starts it | [Read a report](#read-a-report) |
27
+ | `RangeError: Maximum call stack size exceeded` | A synchronous loop through `emit` | [Find a loop](#find-a-loop) |
28
+ | A component renders again and again | A value is sent to the bus on every render | [Find a loop that no chain shows](#find-a-loop-that-no-chain-shows) |
29
+ | You do not know which event leads to which | No map of the events | [Draw the graph](#draw-the-graph) |
30
+
31
+ ## Find a loop
32
+
33
+ `graphLogger` is a trace logger. Pass it to `createTracer` like any other logger. It builds the map of which event leads to which, and writes a report once when a chain passes the same event three times. Add `tracePlugin` so that it also sees every emit and the names of your listeners. It works without the plugin, with a coarser map.
34
+
35
+ ```js
36
+ // bus.js
37
+ import { createBus } from "nanoeffects";
38
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
39
+
40
+ // graphLogger — the map of events and the loop finder, in one logger
41
+ export const graph = graphLogger({ name: "cart" });
42
+
43
+ // createTracer — sends every task of the bus to the logger
44
+ const trace = createTracer(graph);
45
+
46
+ // tracePlugin — reports every emit, so the graph also shows events that nothing listens to
47
+ export const bus = createBus({ trace }).use(tracePlugin());
48
+ ```
49
+
50
+ The handlers are the part of your app that has the loop. Here a cart and a total update each other:
51
+
52
+ ```js
53
+ // cart-handlers.js
54
+ import { bus } from "./bus";
55
+
56
+ // traceId — the name of the listener in the graph and in the report
57
+ bus.on(
58
+ "cart/changed",
59
+ (cart, ctx) => {
60
+ ctx.emit("total/changed", cart);
61
+ },
62
+ { traceId: "recalcTotal" },
63
+ );
64
+
65
+ bus.on(
66
+ "total/changed",
67
+ (total, ctx) => {
68
+ ctx.emit("cart/changed", total);
69
+ },
70
+ { traceId: "syncCart" },
71
+ );
72
+ ```
73
+
74
+ `graphLogger` costs a lookup for each task. Use it while you develop, or with `createTracer(graph, { sample })` in production.
75
+
76
+ ## Read a report
77
+
78
+ <!-- docs-check: continue -->
79
+
80
+ The report is one `console.warn` for each loop:
81
+
82
+ ```text
83
+ [nanoeffects cart] cycle: recalcTotal → total/changed → syncCart → cart/changed → recalcTotal (repeated 3 times, started at cart/changed)
84
+ recalcTotal at src/cart-handlers.js:6; syncCart at src/cart-handlers.js:14 (only with devPlugin)
85
+ handlers feed each other: break the loop with a condition, or emit a different event for the result
86
+ ```
87
+
88
+ - **The path** goes from the first turn of the loop back to it. A named listener shows its event before it.
89
+ - **`started at`** is the first event of the chain.
90
+ - **`at file:line`** appears when you also use [`devPlugin`](hmr.md#report-leaks-with-devplugin).
91
+ - **The last line** is advice for this shape of loop.
92
+
93
+ To receive the finding in code, pass `onCycle`. It replaces the console report:
94
+
95
+ ```js
96
+ // report.js
97
+ import { graphLogger } from "nanoeffects/trace";
98
+
99
+ export const graph = graphLogger({
100
+ // onCycle — called once for each loop; the finding has a path, the repeats and the root
101
+ onCycle: (finding) => {
102
+ console.error(finding.kind, finding.path.join(" → "));
103
+ },
104
+ });
105
+ ```
106
+
107
+ `graph.cycles()` lists the loops found so far, with `turns`: how many times a loop went round after it was found. It also lists cycles that no single chain showed, when the edges of different runs close a loop (`source: "graph"`).
108
+
109
+ ## Draw the graph
110
+
111
+ <!-- docs-check: continue -->
112
+
113
+ The graph has a count on each edge, and the edges of a cycle are marked. Write it in the format your tool reads:
114
+
115
+ ```js
116
+ // draw.js
117
+ import { graph } from "./bus";
118
+
119
+ // Mermaid: paste it into a README, a pull request or https://mermaid.live
120
+ const mermaid = graph.toMermaid({ group: "prefix" }); // group the events by the part before "/"
121
+
122
+ // Graphviz: dot -Tsvg graph.dot > graph.svg
123
+ const dot = graph.toDot();
124
+
125
+ // plain data for your own tools
126
+ const data = graph.toJSON();
127
+
128
+ console.log(mermaid, dot, data);
129
+ ```
130
+
131
+ ```mermaid
132
+ flowchart LR
133
+ n0["cart/changed"]
134
+ n1["recalcTotal"]
135
+ n2["total/changed"]
136
+ n3["syncCart"]
137
+ n0 -->|x21| n1
138
+ n1 -->|x20| n2
139
+ n2 -->|x20| n3
140
+ n3 -->|x20| n0
141
+ linkStyle 0,1,2,3 stroke:red,stroke-width:2px
142
+ ```
143
+
144
+ The counts (`x21`) are how many times each edge was seen; they depend on how long the loop ran.
145
+
146
+ ## See the loop in Redux DevTools
147
+
148
+ `devtools: true` sends each finding to the extension as a `cycle/detected` action. Put `reduxDevtools` next to it to also see the emits:
149
+
150
+ ```js
151
+ // devtools-bus.js
152
+ import { createBus } from "nanoeffects";
153
+ import { createTracer, graphLogger, reduxDevtools, tracePlugin } from "nanoeffects/trace";
154
+
155
+ // devtools: true — one cycle/detected action for each loop, with the path
156
+ const graph = graphLogger({ name: "app", devtools: true });
157
+
158
+ // reduxDevtools — the emits of the bus as actions
159
+ const trace = createTracer([reduxDevtools({ name: "app" }), graph]);
160
+
161
+ export const bus = createBus({ trace }).use(tracePlugin());
162
+ ```
163
+
164
+ Redux DevTools is a browser extension, so open the page in its own tab and pick the instance in its list. Without the extension nothing is sent and nothing throws. You can try it with the buttons of [`examples/debug-playground`](https://github.com/nanoeffects/nanoeffects/tree/main/examples/debug-playground).
165
+
166
+ ## Find a loop that no chain shows
167
+
168
+ Some loops have no chain of emits:
169
+
170
+ - **A component that emits on every render.** `useEmitter` and a `bus.emit` in an effect run outside any handler, so each emit is a root. `graphLogger` counts such emits for each event, and reports a **storm** when one event comes 100 times in a second. Change the limit with `storm: { count, perMs }`, or turn it off with `storm: false`.
171
+ - **A `bus.emit` inside a handler.** It starts a new chain, so the handler that calls it again does not look like a loop. Use `ctx.emit` inside handlers to keep the chain.
172
+ - **A chain of generated names** (`step/${n}`) never repeats a name. `graphLogger` reports a **runaway** chain when one root starts more than `maxSteps` tasks (5000 by default).
173
+
174
+ ```js
175
+ // storm.js
176
+ import { graphLogger } from "nanoeffects/trace";
177
+
178
+ export const graph = graphLogger({
179
+ storm: { count: 50, perMs: 1000 }, // 50 emits of one event in a second from outside
180
+ maxSteps: 2000, // 2000 tasks started by one root
181
+ ignore: ["tree/visit"], // a recursion on purpose is not counted
182
+ });
183
+ ```
184
+
185
+ ## Break the loop
186
+
187
+ | Shape | Fix |
188
+ | ----- | --- |
189
+ | A handler emits its own event | Emit a different event for the result (`user/loaded`, not `user/load`). |
190
+ | Two handlers feed each other | Add the condition that ends it, or send the result to a third event that nothing in the loop listens to. |
191
+ | A job whose outcome has the type of its trigger | Give the outcomes their own types. |
192
+ | A component emits on every render | Pass `equals` to `useEmitter`, so the same value is not an emit, or `debounce` the handler. |
193
+ | A failure re-emits the event that failed | Stop after a number of tries with `retry`, not with an event. |
194
+
195
+ ## Cut a loop at run time
196
+
197
+ The report does not stop a loop. For a loop that must not take the page down, add [`loopGuardPlugin`](loop-guard.md). It refuses the emit that would pass 50 repeats, or a chain of 1000 emits, and reports it:
198
+
199
+ ```js
200
+ // guarded-bus.js
201
+ import { createBus } from "nanoeffects";
202
+ import { loopGuardPlugin } from "nanoeffects/guard";
203
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
204
+
205
+ const graph = graphLogger({ name: "app" });
206
+ const trace = createTracer(graph);
207
+
208
+ // loopGuardPlugin — cuts the loop that the graph reports
209
+ export const bus = createBus({ trace })
210
+ .use(tracePlugin())
211
+ .use(loopGuardPlugin({ maxRepeats: 50 }));
212
+ ```
213
+
214
+ The guard is independent of the logger: use either one alone.
215
+
216
+ ## Fail a test on a loop
217
+
218
+ Run a flow of the app and check that the graph has no cycle. A loop that someone adds later fails the build.
219
+
220
+ ```js
221
+ // bus.js
222
+ import { createBus } from "nanoeffects";
223
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
224
+
225
+ // the bus.js of "Find a loop": the graph logger on the tracer of the bus
226
+ export const graph = graphLogger({ name: "cart" });
227
+ export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
228
+ ```
229
+
230
+ ```js
231
+ // cart-handlers.js
232
+ import { bus } from "./bus";
233
+
234
+ bus.on("checkout/started", (_order, ctx) => {
235
+ ctx.emit("checkout/paid");
236
+ });
237
+ ```
238
+
239
+ ```js
240
+ // cycles.test.js
241
+ import { expect, test } from "vitest";
242
+ import { bus, graph } from "./bus";
243
+ import "./cart-handlers";
244
+
245
+ test("the checkout flow has no event cycles", () => {
246
+ // run the flow the way a user would
247
+ bus.emit("checkout/started");
248
+
249
+ // cycles() — empty when the graph has no loop
250
+ expect(graph.cycles()).toEqual([]);
251
+ });
252
+ ```
253
+
254
+ ## Export the graph and the payloads
255
+
256
+ The exports leave payloads out, because a payload can hold private data. Ask for them when you need them:
257
+
258
+ ```js
259
+ // bus.js
260
+ import { createBus } from "nanoeffects";
261
+ import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
262
+
263
+ // the bus.js of "Find a loop": the graph logger on the tracer of the bus
264
+ export const graph = graphLogger({ name: "cart" });
265
+ export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
266
+ ```
267
+
268
+ ```js
269
+ // payloads.js
270
+ import { graph } from "./bus";
271
+
272
+ // payload: true — adds the last payload and error of each node, made safe
273
+ const data = graph.toJSON({ payload: true });
274
+
275
+ console.log(JSON.stringify(data));
276
+ ```
277
+
278
+ 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.
279
+
280
+ ## Know the limits
281
+
282
+ - A chain that repeats on purpose is reported. List its event in `ignore`.
283
+ - The report is made once for each loop. `turns` in `graph.cycles()` keeps counting.