nanoeffects 0.19.1 → 0.20.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 (46) hide show
  1. package/dist/{action-creator-HFA_LZOH.d.ts → action-creator-Bpa8BUS9.d.ts} +18 -7
  2. package/dist/actions.d.ts +3 -3
  3. package/dist/actions.js +1 -1
  4. package/dist/{context-types-BNcs43eY.d.ts → context-types-BKIH5a8s.d.ts} +1 -1
  5. package/dist/context.d.ts +2 -2
  6. package/dist/dev.d.ts +2 -2
  7. package/dist/dev.js +1 -1
  8. package/dist/effects.d.ts +4 -8
  9. package/dist/index.d.ts +2 -2
  10. package/dist/match.d.ts +1 -1
  11. package/dist/{module-DTf73Wdq.d.ts → module-B0KVDNjY.d.ts} +14 -9
  12. package/dist/mount-each-D54UqAhZ.js +1 -0
  13. package/dist/notrace/{action-creator-CbFf398i.d.ts → action-creator-D2CotugT.d.ts} +16 -7
  14. package/dist/notrace/actions.d.ts +14 -9
  15. package/dist/notrace/actions.js +1 -1
  16. package/dist/notrace/effects.d.ts +4 -8
  17. package/dist/notrace/index.d.ts +2 -2
  18. package/dist/notrace/{plugin-C5vIjxhR.d.ts → plugin-C6KA4dX-.d.ts} +1 -1
  19. package/dist/notrace/policies.d.ts +1 -1
  20. package/dist/notrace/redux.d.ts +1 -1
  21. package/dist/observable.d.ts +1 -1
  22. package/dist/oop.d.ts +1 -1
  23. package/dist/{plugin-Sy8z9IId.d.ts → plugin-D9UTzGRG.d.ts} +2 -2
  24. package/dist/policies.d.ts +2 -2
  25. package/dist/react.d.ts +3 -4
  26. package/dist/redux.d.ts +1 -1
  27. package/dist/scope.d.ts +4 -4
  28. package/dist/signal.d.ts +1 -1
  29. package/dist/state-of-C19H6BoV.d.ts +10 -0
  30. package/dist/trace.d.ts +1 -1
  31. package/dist/{types-DaZT158m.d.ts → types-CbdJKC2z.d.ts} +2 -2
  32. package/dist/vue.d.ts +72 -0
  33. package/dist/vue.js +1 -0
  34. package/dist/{with-policy-C_mX7S54.d.ts → with-policy-BeH8wCkX.d.ts} +1 -1
  35. package/docs/README.md +2 -0
  36. package/docs/actions.md +31 -1
  37. package/docs/chat-example.md +241 -0
  38. package/docs/migrating-from-rxjs.md +58 -1
  39. package/docs/package.md +1 -0
  40. package/docs/plugins.md +3 -3
  41. package/docs/starter.md +14 -1
  42. package/docs/typescript.md +14 -0
  43. package/docs/vue.md +96 -0
  44. package/llms-full.txt +467 -7
  45. package/llms.txt +4 -2
  46. package/package.json +23 -3
package/dist/vue.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { T as EventKey, h as Bus, r as FreeActionCreator, t as ActionCreator, z as EventMap } from "./action-creator-Bpa8BUS9.js";
2
+ import { t as OriginOptions } from "./origin-key-CxK0SkeG.js";
3
+ import { t as StateOf } from "./state-of-C19H6BoV.js";
4
+ import { ShallowRef, WatchSource, WatchStopHandle } from "vue";
5
+ //#region src/vue/plugin.d.ts
6
+ /** Tells whether two values are the same. */
7
+ type Equals<V> = (previous: V, next: V) => boolean;
8
+ /** The third argument of `watchEmitter`. */
9
+ type WatchEmitterOptions<V> = OriginOptions & {
10
+ /** Tells whether a new value is the same as the previous one, for a new object with the same fields. */
11
+ equals?: Equals<V>;
12
+ };
13
+ /** What `vuePlugin` adds to the bus. */
14
+ type VueApi<E extends EventMap> = {
15
+ /**
16
+ * Watches a ref or a getter and emits `event` with its new value when it changes. It
17
+ * does not emit when it is called, and it stops with the effect scope (a component) that
18
+ * called it. Like Vue's `watch`, it runs before the next render, so several changes in
19
+ * one tick emit once, with the last value. The method does not read `this`, so
20
+ * `export const { watchEmitter } = bus` gives a free function. It returns the function
21
+ * that stops it early.
22
+ */
23
+ watchEmitter: {
24
+ <K extends EventKey<E>>(source: WatchSource<E[K]>, event: K, options?: WatchEmitterOptions<E[K]>): WatchStopHandle;
25
+ /** A creator without a payload type: the payload comes from the event map. */
26
+ <K extends EventKey<E>>(source: WatchSource<E[K]>, creator: FreeActionCreator<K>, options?: WatchEmitterOptions<E[K]>): WatchStopHandle;
27
+ <P, T extends string>(source: WatchSource<P>, creator: ActionCreator<P, T>, options?: WatchEmitterOptions<P>): WatchStopHandle;
28
+ };
29
+ /**
30
+ * A ref that starts as `initial`, and that every emit of `event` sets to its payload
31
+ * while the effect scope (a component) that called it lives. A function payload is stored,
32
+ * not called. The bus keeps no values, so an event emitted before the call is not seen. It
33
+ * is state of one component, not a store. The method does not read `this`, so
34
+ * `export const { useEventRef } = bus` gives a free function.
35
+ */
36
+ useEventRef: {
37
+ <K extends EventKey<E>, S>(event: K, initial: StateOf<E[K], S>): ShallowRef<StateOf<E[K], S>>;
38
+ /** A creator without a payload type: the payload comes from the event map. */
39
+ <K extends EventKey<E>, S>(creator: FreeActionCreator<K>, initial: StateOf<E[K], S>): ShallowRef<StateOf<E[K], S>>;
40
+ <P, T extends string>(creator: ActionCreator<P, T>, initial: P): ShallowRef<P>;
41
+ };
42
+ };
43
+ /**
44
+ * Adds `bus.watchEmitter(source, event)` and `bus.useEventRef(event, initial)`: the two
45
+ * jobs of a Vue component with the bus. `watchEmitter` tells the bus when a value of the
46
+ * component changes, and `useEventRef` keeps the last payload of an event in a ref. Pass
47
+ * `{ equals }` to `watchEmitter` for values that `Object.is` tells apart although they are
48
+ * the same, and `{ traceId }` and `{ traceGroupId }` to name the subscription and the flow
49
+ * in the trace of the bus. Needs `vue` 3.3 or newer. Returns the same bus.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { createBus } from "nanoeffects";
54
+ * import { createAction } from "nanoeffects/actions";
55
+ * import { vuePlugin } from "nanoeffects/vue";
56
+ * import { ref } from "vue";
57
+ *
58
+ * export const bus = createBus().use(vuePlugin());
59
+ * export const { watchEmitter, useEventRef } = bus; // free functions
60
+ *
61
+ * const nameChanged = createAction<string>("name/changed");
62
+ * const nameSaved = createAction<string[]>("name/saved");
63
+ *
64
+ * // inside setup():
65
+ * const name = ref("Adam");
66
+ * watchEmitter(name, nameChanged); // nameChanged("…") on every change of name, not on mount
67
+ * const saved = useEventRef(nameSaved, []); // Ref<string[]>, set by every nameSaved
68
+ * ```
69
+ */
70
+ export declare const vuePlugin: () => <E extends EventMap>(bus: Bus<E>) => Bus<E> & VueApi<E>;
71
+ //#endregion
72
+ export type { VueApi };
package/dist/vue.js ADDED
@@ -0,0 +1 @@
1
+ import{t as e}from"./origin-key-BbWN3gmN.js";import{onScopeDispose as t,shallowRef as n,watch as r}from"vue";const i=()=>i=>{function a(t,n,a){return r(t,(t,r)=>{let o=r,s=t;if(a?.equals?.(o,s))return;let c=typeof n==`function`?n(s):{type:n,payload:t},l=Reflect.get(i,e);l?l(c,a??{}):i.emit(c)})}function o(e,r){let a=n(r),o=e;return t(i.on(o,e=>{a.value=e})),a}return Object.assign(i,{watchEmitter:a,useEventRef:o})};export{i as vuePlugin};
@@ -1,4 +1,4 @@
1
- import { R as EventMap, U as Task, V as PayloadOf } from "./action-creator-HFA_LZOH.js";
1
+ import { H as PayloadOf, W as Task, z as EventMap } from "./action-creator-Bpa8BUS9.js";
2
2
  //#region src/policies/with-policy.d.ts
3
3
  /** What `withPolicy` returns: the task as a plain function that can be cancelled. */
4
4
  type PolicyFunction<P, R> = {
package/docs/README.md CHANGED
@@ -18,6 +18,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
18
18
  | Domain logic off the UI | [Actions, commands, modules](actions.md) |
19
19
  | Already on Redux / Zustand / MobX / Pinia | [Stores](stores.md) |
20
20
  | A React form with async checks | [Build a sign-up form in React](react.md) |
21
+ | A Vue app | [Use nanoeffects in a Vue app](vue.md) |
21
22
  | Old responses overwrite new ones | [Cancel stale requests](cancel-stale-requests.md) |
22
23
  | Debounced input that must also cancel the old request | [Debounce and abort](debounce-and-abort.md) |
23
24
  | A second click sends a second request | [Prevent a double submit](prevent-double-submit.md) |
@@ -32,6 +33,7 @@ Code for every guide step: [`examples/guide.ts`](../examples/guide.ts) (type-che
32
33
  - [Effects and cancellation](effects.md) — `retry`, `timeout`, `poll`, `take`, `all`, …
33
34
  - [Actions, commands, jobs and modules](actions.md)
34
35
  - [Sign-up form in React](react.md) — one form: races, double submit, retry
36
+ - [nanoeffects in a Vue app](vue.md) — `watchEmitter` and `useEventRef`
35
37
  - [Context fields](context.md) — store or client on every task
36
38
  - [Use the bus from a class](oop.md) — `extendBus`, `@WithBus`, `@On`, and `dispose()` for the subscriptions of an instance
37
39
  - [TypeScript in many files](typescript.md) — keep the types of the bus in commands and modules
package/docs/actions.md CHANGED
@@ -6,6 +6,8 @@ If you only need `on` / `emit` or policies, stay in the [Guide](guide.md) until
6
6
 
7
7
  Recipe without a UI framework: [`examples/modules.ts`](../examples/modules.ts).
8
8
 
9
+ A chat with four domains, jobs under policies and effects, and a stream tied to a module: [Build a chat with domains, modules and a small server](chat-example.md).
10
+
9
11
  A whole project with two domains, a job and React as a thin layer: `npm create nanoeffects@latest my-app -- --template domains-ts` (see [Start a project from a template](starter.md#start-with-domains)).
10
12
 
11
13
  ## Return a value from `emit` with a command
@@ -252,11 +254,39 @@ app.commands.handle(getUser, fakeUser); // in a test
252
254
  app.dispose(); // unmounts every module
253
255
  ```
254
256
 
257
+ `mount` and `unmount` take any number of modules. Use it to start the domains of an app yourself, at the moment you choose and in the order you give, when you do not give them to `modulePlugin`:
258
+
259
+ ```js
260
+ import { createModule } from "nanoeffects/actions";
261
+ import { app } from "./bus";
262
+
263
+ const userModule = createModule("user", (m) => {
264
+ m.on("user/loaded", (user) => console.log(user));
265
+ });
266
+ const orderModule = createModule("order", (m) => {
267
+ m.on("order/placed", (order) => console.log(order));
268
+ });
269
+
270
+ // mount — start both modules, left to right
271
+ const stop = app.mount(userModule, orderModule);
272
+
273
+ // the returned function stops them again, last mounted first (orderModule, then userModule)
274
+ stop();
275
+
276
+ // the same for modules that you already have in an array: spread it
277
+ const modules = [userModule, orderModule];
278
+ app.mount(...modules);
279
+ app.unmount(...modules); // also in reverse order
280
+ ```
281
+
282
+ If the `setup` of one module throws, the modules of that call that were already mounted are unmounted again and the error is thrown, so a call mounts all of its modules or none. A module that is mounted already is skipped.
283
+
255
284
  - **Unmounting undoes everything** the module did: subscriptions, bound commands, sub-modules, and background tasks (aborted).
256
285
  - **A module is known by its name.** Mounting a module whose name is taken replaces the mounted one (see [Hot reload](hmr.md)).
257
286
  - **Registry.** If `bus.use(commandsPlugin())` ran first, that registry is used; otherwise the module plugin attaches its own. Either way it is `bus.commands` and `app.commands`.
258
287
  - A disposed app cannot mount modules.
259
- - **The given modules are mounted on the first use** of the app or the bus (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`), so the plugins you use after `modulePlugin` still reach them. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
288
+ - **The given modules are mounted at the end of the block that made the app**, or at the first use of the app or the bus if that comes first (the first `bus.emit`, or a call of `mount`, `unmount`, `isMounted`, `dispose`, or the read of `commands`). Nothing needs to use the app: `bus.use(modulePlugin([a, b]))` is enough to start them. The plugins that you use after `modulePlugin` in the same block still reach them, because the modules wait until that block has finished. A plugin used after an `await` can come too late: use every plugin before the first `await`. The `bus.on` calls made in between are held and made right after, so the listeners of a module stay ahead of them. See [using plugins in any order](plugins.md#use-them-in-any-order).
289
+ - **To mount by hand instead**, pass no modules: `modulePlugin()` starts nothing, and `app.mount(...)` starts the modules when you want.
260
290
  - Module context = task context plus `handle`, `use`, and `spawn`:
261
291
  - `handle` — give a command a handler (contract, or override).
262
292
  - `use` — bring sub-modules and commands that carry a handler; the returned function undoes that call.
@@ -0,0 +1,241 @@
1
+ # Build a chat with domains, modules and a small server
2
+
3
+ Use this page when your app has several features that react to each other and you want to see where each piece of logic goes. The example is a chat with rooms, a history that loads, messages that can fail to send, a search across all rooms, and new messages that arrive on their own. A small Fastify server in the example answers every request, and it misbehaves on a known schedule, so each problem on this page really happens.
4
+
5
+ It is a level D example. Start with [Actions, commands, jobs and modules](actions.md) for the API, or with the `domains-ts` template in [Start a project from a template](starter.md#start-with-domains) for a smaller project.
6
+
7
+ The code is in [`examples/chat`](../examples/chat). Run it on [StackBlitz](https://stackblitz.com/github/nanoeffects/nanoeffects/tree/main/examples/chat), or on your machine:
8
+
9
+ ```bash
10
+ cd examples/chat
11
+ npm install
12
+ npm run dev
13
+ ```
14
+
15
+ `npm run dev` starts the server and Vite together. Start it with `CHAOS=off npm run dev` to turn the failures off.
16
+
17
+ ## Read how the server fails
18
+
19
+ The server keeps its data in memory. These rules are on purpose, and they are the same every run, so you can follow each section below by doing what the table says:
20
+
21
+ | What you do | What the server does | Section |
22
+ | ----------- | -------------------- | ------- |
23
+ | Select a room, then another before the first is shown | The room Random loads its history in 1.2 s, slower than the others. | [Load a history that must not race](#load-a-history-that-must-not-race) |
24
+ | Send messages | Every third send answers 503. | [Send a message that can fail](#send-a-message-that-can-fail) |
25
+ | Send `/fail hello` | Answers 400. | [Send a message that can fail](#send-a-message-that-can-fail) |
26
+ | Search a word that is in Random | Random answers after 2.5 s. | [Search where one room is slow](#search-where-one-room-is-slow) |
27
+ | Wait | A new message appears every 4 s. | [Poll for new messages](#poll-for-new-messages) |
28
+
29
+ Open the Network panel of the browser while you do this. A request that the page aborts shows as cancelled there, and the server prints `aborted: ...` for it.
30
+
31
+ ## See how the files are split
32
+
33
+ Each domain is a folder. The components only emit events and read values; they never call the server.
34
+
35
+ | Path | Job |
36
+ | ---- | --- |
37
+ | `src/bus.ts` | Creates the bus. It imports no events and no domain. |
38
+ | `src/app.ts` | Mounts the four domains. |
39
+ | `src/api.ts` | The client of the server: one function per request. |
40
+ | `server/` | The Fastify server and the script that starts it with Vite. |
41
+ | `src/domains/rooms/` | The list of rooms and the selected room. |
42
+ | `src/domains/messages/` | The history of the open room and sending a message. |
43
+ | `src/domains/search/` | Searching every room and opening a result. |
44
+ | `src/domains/incoming/` | New messages, asked for at an interval. |
45
+ | `src/ui/` | The components. |
46
+
47
+ A domain folder has its events (`rooms.ts`, which imports no bus), its work (`rooms-handlers.ts`), the state it keeps (`rooms-store.ts`) and a module (`rooms-module.ts`) that wires them. A domain reacts to another domain by listening to its events, never by importing its handlers or its store.
48
+
49
+ ## Load a history that must not race
50
+
51
+ **Problem.** The reader clicks a slow room and then a fast one. If the slow answer arrives last, it shows the wrong room, and a hand-written loading flag stays `true` when a request is aborted.
52
+
53
+ **When.** A load starts from a user choice that can change before it ends.
54
+
55
+ A job sends `pending` when its handler starts and exactly one of `success`, `fail` and `cancel` when it ends. `latest` around the handler aborts the older load when a newer one starts, so only the newer result is delivered, and the older one sends `cancel`:
56
+
57
+ ```js
58
+ // messages.js
59
+ import { createJob } from "nanoeffects/actions";
60
+
61
+ // createJob — a command that also sends pending, success, fail and cancel
62
+ export const loadHistory = createJob("messages/loadHistory");
63
+ ```
64
+
65
+ ```js
66
+ // messages-handlers.js
67
+ import { latest } from "nanoeffects/policies";
68
+
69
+ // latest — a load for a newer room aborts the older one, and its result is never delivered
70
+ export const fetchHistory = latest((roomId, { signal }) => api.loadHistory(roomId, { signal }));
71
+ ```
72
+
73
+ The module gives the job its work and keeps the state from the outcomes, so an aborted load clears the flag too (see [Show a loading state with a job](actions.md#show-a-loading-state-with-a-job)):
74
+
75
+ ```js
76
+ // messages-module.js
77
+ import { createModule } from "nanoeffects/actions";
78
+ import { loadHistory } from "./messages";
79
+ import { fetchHistory } from "./messages-handlers";
80
+
81
+ export const messagesModule = createModule("messages", (module) => {
82
+ // handle — this module does the work of loadHistory while it is mounted
83
+ module.handle(loadHistory, fetchHistory);
84
+ module.on(loadHistory.pending, () => console.log("show the spinner"));
85
+ module.on(loadHistory.cancel, () => console.log("a newer load replaced it"));
86
+ });
87
+ ```
88
+
89
+ ## Send a message that can fail
90
+
91
+ **Problem.** A message must appear at once, go out in the order it was sent, be tried again after a failure, and show as failed if it never arrives. The outcome `fail` carries only the error, not the call, so the page cannot tell which message failed.
92
+
93
+ **When.** An action has a visible result that can fail and that must keep its order.
94
+
95
+ `queue` keeps the order, `retry` tries a failed call again, and the handler throws an error that carries the id of the message:
96
+
97
+ ```js
98
+ import { retry } from "nanoeffects/effects";
99
+ import { queue } from "nanoeffects/policies";
100
+
101
+ class SendFailed extends Error {
102
+ constructor(localId, cause) {
103
+ super(cause.message);
104
+ this.localId = localId;
105
+ }
106
+ }
107
+
108
+ // A 400 is an answer: asking again gives the same answer. A 503 or a lost connection may pass.
109
+ const isWorthRetrying = (error) => !(error.status < 500);
110
+
111
+ // retry — try a failed send again, up to 3 attempts, 200 ms apart, unless the answer was a "no"
112
+ const attempt = retry(
113
+ async (call, { signal }) => {
114
+ const message = await api.send(call.roomId, call.text, { signal });
115
+ return { localId: call.localId, message };
116
+ },
117
+ { times: 3, delay: 200, shouldRetry: isWorthRetrying },
118
+ );
119
+
120
+ // queue — messages go out one after another, in the order they were sent
121
+ export const deliver = queue(async (call, ctx) => {
122
+ try {
123
+ return await attempt(call, ctx);
124
+ } catch (error) {
125
+ throw new SendFailed(call.localId, error);
126
+ }
127
+ });
128
+ ```
129
+
130
+ The module adds the message on `pending`, marks it delivered on `success`, and marks the message named by the error as failed on `fail`. Send `/fail hello` to see a failure that is not tried again, and send a few messages to meet the 503 that is.
131
+
132
+ ## Search where one room is slow
133
+
134
+ **Problem.** A search asks every room. If it waits for all of them and one room is slow or broken, the reader sees nothing. If it fails on the first error, the rooms that answered are lost.
135
+
136
+ **When.** One action fans out to several sources and a partial answer is better than none.
137
+
138
+ `timeout` limits each request, and `allSettled` waits for every request to end, whether it succeeded or failed. The handler keeps the hits and the names of the rooms that did not answer:
139
+
140
+ ```js
141
+ import { allSettled, timeout } from "nanoeffects/effects";
142
+ import { debounce, latest } from "nanoeffects/policies";
143
+
144
+ const rooms = [{ id: "general", name: "General" }, { id: "random", name: "Random" }];
145
+
146
+ const searchRoom = (room, query) => ({ signal }) => api.searchRoom(room.id, query, { signal });
147
+
148
+ // timeout — a room that does not answer in 1.5 s fails with a timeout error
149
+ const askRoom = (room, query) => {
150
+ const request = searchRoom(room, query);
151
+ return (parent) => timeout(parent, 1500, request);
152
+ };
153
+
154
+ // debounce — wait for quiet; latest — a newer query aborts the older search
155
+ export const runSearch = debounce(
156
+ 300,
157
+ latest(async (query, ctx) => {
158
+ // allSettled — ask every room at once and wait for all of them, however each one ends
159
+ const outcomes = await allSettled(ctx, rooms.map((room) => askRoom(room, query)));
160
+ return outcomes.map((outcome, index) => ({ room: rooms[index], outcome }));
161
+ }),
162
+ );
163
+ ```
164
+
165
+ `all` would fail the whole search on the first slow room, which is the wrong behavior here. See [Effects](effects.md) for the others.
166
+
167
+ ## Wait for a room before marking a result
168
+
169
+ **Problem.** The reader opens a search result from another room. The message cannot be marked until that room's history has loaded, and the handler must not guess how long that takes.
170
+
171
+ **When.** A handler starts work in another domain and needs to continue after its outcome.
172
+
173
+ `take` waits for the next event of a name. Start it before the emit that causes the event, so nothing is missed:
174
+
175
+ ```js
176
+ import { take } from "nanoeffects/effects";
177
+
178
+ // the room that is open, kept from the rooms/selected event
179
+ let selectedRoom = "general";
180
+
181
+ export const openResult = async (hit, ctx) => {
182
+ if (hit.roomId !== selectedRoom) {
183
+ // take — wait for the next event of that name; started before the emit so nothing is missed
184
+ const loaded = take(ctx, "messages/loadHistory/success");
185
+ ctx.emit("rooms/selected", hit.roomId);
186
+ await loaded;
187
+ }
188
+ ctx.emit("messages/marked", hit.id);
189
+ };
190
+ ```
191
+
192
+ ## Poll for new messages
193
+
194
+ **Problem.** New messages come from the server, and the page has to ask for them at an interval. The polling must stop when the feature is switched off, and one failed request must not stop it.
195
+
196
+ **When.** A feature has background work that lives as long as the feature does.
197
+
198
+ `poll` calls a function now and then at an interval, and ends when its context is aborted. A module starts the task with `spawn`, and unmounting the module aborts it:
199
+
200
+ ```js
201
+ import { createModule } from "nanoeffects/actions";
202
+ import { poll } from "nanoeffects/effects";
203
+
204
+ const watchUpdates = async (_, ctx) => {
205
+ // the number of the last update seen; the first answer only tells where the server is
206
+ let since;
207
+
208
+ const check = async () => {
209
+ try {
210
+ const updates = await api.loadUpdates(since, { signal: ctx.signal });
211
+ since = updates.seq;
212
+ for (const message of updates.messages) {
213
+ // ctx.emit — after an abort it delivers nothing
214
+ ctx.emit("incoming/received", message);
215
+ }
216
+ } catch {
217
+ // one failed request must not end the polling: the next tick asks again
218
+ }
219
+ };
220
+
221
+ // poll — call `check` now and then every 3 s; an abort of ctx (an unmounted module) ends it
222
+ await poll(ctx, check, 3000, { leading: true });
223
+ };
224
+
225
+ export const incomingModule = createModule("incoming", (module) => {
226
+ // spawn — a background task that lives as long as this module
227
+ module.spawn(watchUpdates);
228
+ });
229
+ ```
230
+
231
+ In the example a button calls `app.unmount(incomingModule)` and `app.mount(incomingModule)`, so you can watch the polling stop (the Network panel shows no more `updates` requests) and start.
232
+
233
+ ## Know where each value lives
234
+
235
+ Each domain keeps its state in a [Zustand](https://zustand.docs.pmnd.rs/) store that the module writes and a component reads with `useStore`. Each store lists its mutations, such as `startLoading(roomId)` and `markFailed(localId)`, and a module calls them with the data of an event: `module.on(loadHistory.pending, startLoading)`. A mutation that sets plain values gives `set` an object, as in `set({ searching: true })`. The store of the messages uses the `immer` middleware, so a mutation that changes a list can change it in place, as in `state.messages.push(message)`, and the store still gives React a new object. Nothing else writes the state. The list of rooms is the exception: it is read with one `useEventState` in a provider at the root. `useEventState` is not a store: an event that came before the provider mounted is lost, and a second component gets its own copy. See [Show the last value of an event as state](react.md#show-the-last-value-of-an-event-as-state).
236
+
237
+ ## Where to go next
238
+
239
+ - The API of commands, jobs and modules: [Actions, commands, jobs and modules](actions.md).
240
+ - Policies such as `latest` and `queue`: [Policies](policies.md). Effects such as `allSettled`, `take` and `poll`: [Effects](effects.md).
241
+ - A smaller project with two domains: [Start with domains](starter.md#start-with-domains).
@@ -1,6 +1,6 @@
1
1
  # Use nanoeffects next to RxJS
2
2
 
3
- Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows one search box both ways, and lists what stays in RxJS.
3
+ Read this page if your project uses RxJS and you wonder what nanoeffects takes over. It does not replace RxJS. The two split the work: RxJS shapes streams of values, nanoeffects runs the side effects that events start. It maps the operators, shows a search box and a polling loop both ways, and lists what stays in RxJS.
4
4
 
5
5
  ## Split the work
6
6
 
@@ -82,6 +82,63 @@ input.addEventListener("input", (event) => bus.emit("typed", event.target.value)
82
82
 
83
83
  The skip of repeated values is not here. Add `if (query === lastQuery) return;` to the handler, or keep that one step in RxJS (next section).
84
84
 
85
+ ## Rewrite a polling loop
86
+
87
+ Check a job status every 5 seconds. Retry a failed check, and stop when the user leaves the screen.
88
+
89
+ The same job in RxJS: `timer` ticks, `exhaustMap` skips a tick while a check runs, `retry` repeats a failed check, `takeUntil` stops it.
90
+
91
+ <!-- docs-check: skip - needs rxjs, which the project does not depend on -->
92
+ ```js
93
+ // RxJS
94
+ import { timer } from "rxjs";
95
+ import { exhaustMap, retry, takeUntil } from "rxjs/operators";
96
+
97
+ timer(5000, 5000)
98
+ .pipe(
99
+ exhaustMap(() => api.statusStream().pipe(retry({ count: 3, delay: 500 }))),
100
+ takeUntil(leave$),
101
+ )
102
+ .subscribe((status) => render(status));
103
+ ```
104
+
105
+ The same job in nanoeffects. `poll` ticks, `retry` repeats one check, and `race` ends the loop on the first `"leave"` event:
106
+
107
+ ```js
108
+ import { createBus } from "nanoeffects";
109
+ import { poll, race, retry, take } from "nanoeffects/effects";
110
+
111
+ const bus = createBus(); // the bus: events and handlers
112
+
113
+ bus.on("watch", async (_, ctx) => {
114
+ // one check, up to 3 attempts, 500 ms between them
115
+ const checkStatus = async () => {
116
+ const status = await retry(ctx, () => api.status({ signal: ctx.signal }), {
117
+ times: 3,
118
+ delay: 500,
119
+ });
120
+ ctx.emit("status", status);
121
+ };
122
+
123
+ await race(ctx, {
124
+ // poll: a check every 5 s; waitForFn: the next pause starts after the check ends
125
+ polling: ({ signal }) => poll({ signal }, checkStatus, 5000, { waitForFn: true }),
126
+ // take: resolves at the next "leave", and race stops the polling
127
+ leave: ({ signal }) => take(ctx, "leave", { signal }),
128
+ });
129
+ });
130
+
131
+ bus.on("status", (status) => render(status));
132
+
133
+ bus.emit("watch"); // start
134
+ bus.emit("leave"); // stop
135
+ ```
136
+
137
+ Two things differ from the pipeline:
138
+
139
+ - `waitForFn: true` plays the role of `exhaustMap`: checks never overlap. Without it `poll` keeps a steady rate, and a slow check can overlap the next one.
140
+ - If all 3 attempts fail, the error stops `poll`, as an error ends an Observable. To keep polling after a failed check, catch it inside `checkStatus` and emit an `"error"` event.
141
+
85
142
  ## Use both in one app
86
143
 
87
144
  Keep RxJS where a stream is how you think, and let events cross to the bus in either direction.
package/docs/package.md CHANGED
@@ -16,6 +16,7 @@ Imports are split so you only load what you use. `createBus` stays small; the re
16
16
  | `nanoeffects/redux` | `createEffectsMiddleware` |
17
17
  | `nanoeffects/observable` | `createObserve`, `Subject` |
18
18
  | `nanoeffects/react` | `reactPlugin`: adds the `useEmitter` hook to a bus (needs React 18 or newer) |
19
+ | `nanoeffects/vue` | `vuePlugin`: adds `watchEmitter` and `useEventRef` to a bus (needs Vue 3.3 or newer) |
19
20
  | `nanoeffects/dev` | `devPlugin` (no-op in production) |
20
21
  | `nanoeffects/trace` | `createTracer`, the loggers and `tracePlugin` (names for the trace) |
21
22
 
package/docs/plugins.md CHANGED
@@ -82,7 +82,7 @@ import { modulePlugin } from "nanoeffects/actions";
82
82
 
83
83
  const bus = createBus();
84
84
 
85
- // modulePlugin — mounts userModule on the first use of the app or the bus, and returns the app
85
+ // modulePlugin — mounts userModule at the end of this block, or at the first use if that is earlier, and returns the app
86
86
  const app = bus.use(modulePlugin([userModule]));
87
87
 
88
88
  app.unmount(userModule); // its listeners, bound commands and tasks are gone
@@ -261,7 +261,7 @@ export const bus = createBus()
261
261
  // commandsPlugin — the registry of command handlers; keep it to bind handlers later
262
262
  export const commands = bus.use(commandsPlugin());
263
263
 
264
- // modulePlugin — mounts userModule on the first use; keep app to mount and unmount later
264
+ // modulePlugin — mounts userModule at the end of this block; keep app to mount and unmount later
265
265
  export const app = bus.use(modulePlugin([userModule]));
266
266
 
267
267
  // devPlugin — reports leaks and modules while you develop; keep disconnect to remove it
@@ -274,7 +274,7 @@ The plugins give the same result in any order. Use them as they read best, and p
274
274
 
275
275
  Two things happen because modules are mounted late:
276
276
 
277
- - **Modules start on the first use.** `modulePlugin([userModule])` mounts `userModule` when the app or the bus is first used: the first `bus.emit`, or the first call of `app.mount`, `app.unmount`, `app.isMounted`, `app.dispose` or the read of `app.commands`. By then every plugin is on the bus, so a module and the tasks it spawns have the fields, effects and scopes of the plugins you used after `modulePlugin`, and `m.on(fn, handler)` works with `matchPlugin`.
277
+ - **Modules start at the end of the block.** `modulePlugin([userModule])` mounts `userModule` when the synchronous block that made it has finished, or earlier if the app or the bus is used first: the first `bus.emit`, or the first call of `app.mount`, `app.unmount`, `app.isMounted`, `app.dispose` or the read of `app.commands`. By then every plugin used in the same block is on the bus, so a module and the tasks it spawns have the fields, effects and scopes of the plugins you used after `modulePlugin`, and `m.on(fn, handler)` works with `matchPlugin`. A plugin used after an `await` can come too late: use every plugin before the first `await`. With no module, `modulePlugin()` starts nothing until the first use.
278
278
  - **Listeners keep their place.** A `bus.on` after `bus.use(modulePlugin(...))` is held until the modules are mounted, then made, in the order you wrote them. The listeners of a module therefore stay ahead of the ones registered after the plugin, and behind the ones registered before it. The function `bus.on` returns works before and after.
279
279
 
280
280
  Other plugins do not need an order for another reason:
package/docs/starter.md CHANGED
@@ -47,6 +47,7 @@ Pass `--template` to skip the question. With npm, put `--` before the option.
47
47
  | `vanilla-ts` | The `vanilla` app in TypeScript | You want the compiler to check events and handlers |
48
48
  | `react-ts` | The `react` app in TypeScript | You build a React app in TypeScript |
49
49
  | `domains-ts` | Commands, a job and modules, with the logic split into domain folders and React as a thin layer | You build an app with more than one feature and want the logic off the UI |
50
+ | `vue-ts` | The `react-ts` app in Vue 3, with `watchEmitter` and `useEventRef` from `nanoeffects/vue` | You build a Vue 3 app |
50
51
 
51
52
  ```bash
52
53
  npm create nanoeffects@latest my-app -- --template react
@@ -60,6 +61,16 @@ The TypeScript templates are the same apps with the same files, written in `.ts`
60
61
  npm create nanoeffects@latest my-app -- --template react-ts
61
62
  ```
62
63
 
64
+ ### Start with Vue
65
+
66
+ `vue-ts` is the search app of `react-ts` in Vue 3 with `<script setup lang="ts">`. Its bus uses `vuePlugin` from `nanoeffects/vue`: `watchEmitter` sends the query to the bus when it changes, and `useEventRef` keeps the result of the search in a `ref`. Like `useEventState` in React, `useEventRef` is [not a store](vue.md#it-is-not-a-store): a value that several components share belongs in a store such as Pinia.
67
+
68
+ ```bash
69
+ npm create nanoeffects@latest my-app -- --template vue-ts
70
+ ```
71
+
72
+ `npm run build` type-checks the `.vue` files with `vue-tsc`, which needs TypeScript 5: the template depends on it.
73
+
63
74
  ### Start with domains
64
75
 
65
76
  `domains-ts` is not the search box of the other templates grown in TypeScript: it shows where logic goes when an app has several features. Each domain is a folder under `src/domains/`. It has the events of the domain, the work that handles them, and a module that mounts the work. The React components only emit events and read values.
@@ -76,11 +87,13 @@ npm create nanoeffects@latest my-app -- --template domains-ts
76
87
  | `src/domains/checkout/` | Placing an order: `checkout.ts` (a job), `checkout-handlers.ts` (`leading` and `retry`), `checkout-store.ts` (state written from the job's outcomes), `checkout-module.ts`. |
77
88
  | `src/ui/` | The components. None of them calls the fake server in `src/api.ts`. |
78
89
 
90
+ For a bigger example with the same layout, a chat with four domains and mocks, see [Build a chat with domains, modules and a small server](chat-example.md).
91
+
79
92
  The commands, jobs and modules themselves are explained in [Actions, commands, jobs and modules](actions.md). The template keeps found products in a Context provider with one `useEventState`, and the order state in a store of its own: `useEventState` is [not a store](react.md#show-the-last-value-of-an-event-as-state).
80
93
 
81
94
  ## Read the files
82
95
 
83
- Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for components).
96
+ Every template except `domains-ts` splits the code the way [the guide](guide.md) does: one job per file. In the TypeScript templates the files end in `.ts` (and `.tsx` for React components, `.vue` for Vue components).
84
97
 
85
98
  | File | Job |
86
99
  | ---- | --- |
@@ -129,6 +129,20 @@ const user = bus.emit(getUser("1")); // Promise<User>
129
129
 
130
130
  `createModule` of the app only creates the module: it does not mount it, and it keeps no link to the bus. That is why the app in `bus.ts` starts without modules. `createModule` from `nanoeffects/actions` stays for a module made without a bus: there you write the event map yourself, `createModule<AppEvents>("user", …)`.
131
131
 
132
+ ## Type a function that takes the signal
133
+
134
+ A function that makes a request should take the `signal` of the task, so aborting the task cancels the request. `WithSignal` is the type for that parameter: an object with a `signal`. It is checker-only, like `WithContext`: it adds nothing to your bundle.
135
+
136
+ ```ts
137
+ import type { WithSignal } from "nanoeffects";
138
+
139
+ // WithSignal — { signal: AbortSignal }; a task context fits it, and so does { signal } on its own
140
+ const search = (query: string, { signal }: WithSignal) =>
141
+ fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
142
+ ```
143
+
144
+ Pass `ctx` (or `{ signal }`) when you call it. The effects of `nanoeffects/effects` (`retry`, `timeout`, `all`) take an optional signal instead, because outside a task you may pass `{}`; that is a different type, and you do not write it.
145
+
132
146
  ## Keep the contract apart from the code
133
147
 
134
148
  When the file with the command must not import the code that implements it, make the command without a handler. Here you write the payload and the result once, as generics, and the handler goes to a module, where it needs no annotation: