@silvery/tea 0.3.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/package.json +54 -0
- package/src/core/index.ts +225 -0
- package/src/core/slice.ts +69 -0
- package/src/create-command-registry.ts +106 -0
- package/src/effects.ts +145 -0
- package/src/focus-events.ts +243 -0
- package/src/focus-manager.ts +491 -0
- package/src/focus-queries.ts +241 -0
- package/src/index.ts +213 -0
- package/src/keys.ts +1382 -0
- package/src/pipe.ts +110 -0
- package/src/plugins.ts +119 -0
- package/src/store/index.ts +306 -0
- package/src/streams/index.ts +405 -0
- package/src/tea/README.md +208 -0
- package/src/tea/index.ts +174 -0
- package/src/text-cursor.ts +206 -0
- package/src/text-decorations.ts +253 -0
- package/src/text-ops.ts +150 -0
- package/src/tree-utils.ts +27 -0
- package/src/types.ts +670 -0
- package/src/with-commands.ts +337 -0
- package/src/with-diagnostics.ts +955 -0
- package/src/with-dom-events.ts +168 -0
- package/src/with-focus.ts +162 -0
- package/src/with-keybindings.ts +180 -0
- package/src/with-react.ts +92 -0
- package/src/with-render.ts +92 -0
- package/src/with-terminal.ts +219 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AsyncIterable stream helpers for event-driven TUI architecture.
|
|
3
|
+
*
|
|
4
|
+
* These are pure functions over AsyncIterables - no EventEmitters, no callbacks.
|
|
5
|
+
* All helpers properly handle cleanup via return() on early break.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const keys = term.keys()
|
|
10
|
+
* const resizes = term.resizes()
|
|
11
|
+
*
|
|
12
|
+
* // Merge multiple sources
|
|
13
|
+
* const events = merge(
|
|
14
|
+
* map(keys, k => ({ type: 'key', ...k })),
|
|
15
|
+
* map(resizes, r => ({ type: 'resize', ...r }))
|
|
16
|
+
* )
|
|
17
|
+
*
|
|
18
|
+
* // Consume until ctrl+c
|
|
19
|
+
* for await (const event of events) {
|
|
20
|
+
* if (event.type === 'key' && event.key === 'ctrl+c') break
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Merge multiple AsyncIterables into one.
|
|
27
|
+
*
|
|
28
|
+
* Values are emitted in arrival order (first-come). When all sources complete,
|
|
29
|
+
* the merged iterable completes. If any source throws, the error propagates
|
|
30
|
+
* and remaining sources are cleaned up.
|
|
31
|
+
*
|
|
32
|
+
* IMPORTANT: Each call to merge() creates a fresh iterable. Don't share
|
|
33
|
+
* the same merged iterable between multiple consumers.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* const merged = merge(keys, resizes, ticks)
|
|
38
|
+
* for await (const event of merged) {
|
|
39
|
+
* // Process events from any source
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export async function* merge<T>(...sources: AsyncIterable<T>[]): AsyncGenerator<T, void, undefined> {
|
|
44
|
+
if (sources.length === 0) return
|
|
45
|
+
|
|
46
|
+
// Track active iterators and their pending promises
|
|
47
|
+
const iterators = sources.map((source) => source[Symbol.asyncIterator]())
|
|
48
|
+
const pending = new Map<number, Promise<{ index: number; result: IteratorResult<T, unknown> }>>()
|
|
49
|
+
|
|
50
|
+
async function nextWithIndex(idx: number): Promise<{ index: number; result: IteratorResult<T, unknown> }> {
|
|
51
|
+
const iterator = iterators[idx]
|
|
52
|
+
if (!iterator) throw new Error(`No iterator at index ${idx}`)
|
|
53
|
+
const result = await iterator.next()
|
|
54
|
+
return { index: idx, result }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Start all iterators
|
|
58
|
+
for (let i = 0; i < iterators.length; i++) {
|
|
59
|
+
pending.set(i, nextWithIndex(i))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
while (pending.size > 0) {
|
|
64
|
+
// Race all pending promises
|
|
65
|
+
const { index, result } = await Promise.race(pending.values())
|
|
66
|
+
|
|
67
|
+
if (result.done) {
|
|
68
|
+
// This source is exhausted, remove it
|
|
69
|
+
pending.delete(index)
|
|
70
|
+
} else {
|
|
71
|
+
// Yield the value and request next from this source
|
|
72
|
+
yield result.value
|
|
73
|
+
pending.set(index, nextWithIndex(index))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
// Clean up all iterators on early exit or error
|
|
78
|
+
await Promise.all(iterators.map((it) => (it.return ? it.return() : Promise.resolve())))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Transform each value from an AsyncIterable.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* const keyEvents = map(keys, k => ({ type: 'key' as const, key: k }))
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export async function* map<T, U>(source: AsyncIterable<T>, fn: (value: T) => U): AsyncGenerator<U, void, undefined> {
|
|
91
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
92
|
+
try {
|
|
93
|
+
// Use the iterator directly to avoid double-iteration
|
|
94
|
+
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
|
|
95
|
+
yield fn(value)
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
if (iterator.return) {
|
|
99
|
+
await iterator.return()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Filter values from an AsyncIterable.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* const letters = filter(keys, k => k.key.length === 1)
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export async function* filter<T>(
|
|
113
|
+
source: AsyncIterable<T>,
|
|
114
|
+
predicate: (value: T) => boolean,
|
|
115
|
+
): AsyncGenerator<T, void, undefined> {
|
|
116
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
117
|
+
try {
|
|
118
|
+
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
|
|
119
|
+
if (predicate(value)) {
|
|
120
|
+
yield value
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
if (iterator.return) {
|
|
125
|
+
await iterator.return()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Filter and transform in one pass (type narrowing).
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* const keyEvents = filterMap(events, e =>
|
|
136
|
+
* e.type === 'key' ? e : undefined
|
|
137
|
+
* )
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export async function* filterMap<T, U>(
|
|
141
|
+
source: AsyncIterable<T>,
|
|
142
|
+
fn: (value: T) => U | undefined,
|
|
143
|
+
): AsyncGenerator<U, void, undefined> {
|
|
144
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
145
|
+
try {
|
|
146
|
+
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
|
|
147
|
+
const mapped = fn(value)
|
|
148
|
+
if (mapped !== undefined) {
|
|
149
|
+
yield mapped
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
if (iterator.return) {
|
|
154
|
+
await iterator.return()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Take values until an AbortSignal fires.
|
|
161
|
+
*
|
|
162
|
+
* When the signal aborts, the iterator completes gracefully (no error thrown).
|
|
163
|
+
* The source iterator is properly cleaned up.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* const controller = new AbortController()
|
|
168
|
+
* const events = takeUntil(allEvents, controller.signal)
|
|
169
|
+
*
|
|
170
|
+
* // Later: controller.abort() will end the iteration
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export async function* takeUntil<T>(source: AsyncIterable<T>, signal: AbortSignal): AsyncGenerator<T, void, undefined> {
|
|
174
|
+
if (signal.aborted) return
|
|
175
|
+
|
|
176
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
177
|
+
|
|
178
|
+
// Create a promise that resolves when signal aborts
|
|
179
|
+
let abortResolve: () => void
|
|
180
|
+
const abortPromise = new Promise<void>((resolve) => {
|
|
181
|
+
abortResolve = resolve
|
|
182
|
+
})
|
|
183
|
+
const onAbort = () => abortResolve()
|
|
184
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
while (!signal.aborted) {
|
|
188
|
+
// Race between next value and abort
|
|
189
|
+
const result = await Promise.race([
|
|
190
|
+
iterator.next(),
|
|
191
|
+
abortPromise.then(() => ({ done: true, value: undefined }) as const),
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
if (result.done) break
|
|
195
|
+
yield result.value as T
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
signal.removeEventListener("abort", onAbort)
|
|
199
|
+
if (iterator.return) {
|
|
200
|
+
await iterator.return()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Take the first n values from an AsyncIterable.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```typescript
|
|
210
|
+
* const firstThree = take(events, 3)
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export async function* take<T>(source: AsyncIterable<T>, count: number): AsyncGenerator<T, void, undefined> {
|
|
214
|
+
if (count <= 0) return
|
|
215
|
+
|
|
216
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
217
|
+
let taken = 0
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
|
|
221
|
+
yield value
|
|
222
|
+
taken++
|
|
223
|
+
if (taken >= count) break
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
if (iterator.return) {
|
|
227
|
+
await iterator.return()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create an AsyncIterable from an array (useful for testing).
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```typescript
|
|
237
|
+
* const events = fromArray([
|
|
238
|
+
* { type: 'key', key: 'j' },
|
|
239
|
+
* { type: 'key', key: 'k' },
|
|
240
|
+
* ])
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
export async function* fromArray<T>(items: T[]): AsyncGenerator<T, void, undefined> {
|
|
244
|
+
for (const item of items) {
|
|
245
|
+
yield item
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Create an AsyncIterable that yields after a delay (useful for testing).
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* ```typescript
|
|
254
|
+
* const delayed = fromArrayWithDelay([1, 2, 3], 100) // 100ms between each
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
export async function* fromArrayWithDelay<T>(items: T[], delayMs: number): AsyncGenerator<T, void, undefined> {
|
|
258
|
+
for (const item of items) {
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
260
|
+
yield item
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Throttle high-frequency sources.
|
|
266
|
+
*
|
|
267
|
+
* Emits the first value immediately, then ignores values for the specified
|
|
268
|
+
* duration. After the duration, the next value is emitted and the cycle repeats.
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```typescript
|
|
272
|
+
* const throttled = throttle(mouseMoves, 16) // ~60fps
|
|
273
|
+
* ```
|
|
274
|
+
*/
|
|
275
|
+
export async function* throttle<T>(source: AsyncIterable<T>, ms: number): AsyncGenerator<T, void, undefined> {
|
|
276
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
277
|
+
let lastEmit = 0
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
|
|
281
|
+
const now = Date.now()
|
|
282
|
+
if (now - lastEmit >= ms) {
|
|
283
|
+
lastEmit = now
|
|
284
|
+
yield value
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} finally {
|
|
288
|
+
if (iterator.return) {
|
|
289
|
+
await iterator.return()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Debounce values - only emit after source is quiet for specified duration.
|
|
296
|
+
*
|
|
297
|
+
* NOTE: With pull-based AsyncIterables, true debouncing is complex. This
|
|
298
|
+
* implementation collects all values and only yields the final value after
|
|
299
|
+
* the source completes and quiet period passes. For real-time debouncing,
|
|
300
|
+
* consider using a push-based pattern or EventEmitter.
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```typescript
|
|
304
|
+
* const debounced = debounce(searchInput, 300) // Yields last value after source ends + delay
|
|
305
|
+
* ```
|
|
306
|
+
*/
|
|
307
|
+
export async function* debounce<T>(source: AsyncIterable<T>, ms: number): AsyncGenerator<T, void, undefined> {
|
|
308
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
309
|
+
let last: { value: T } | undefined
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
|
|
313
|
+
last = { value }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (last) {
|
|
317
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
318
|
+
yield last.value
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
if (iterator.return) {
|
|
322
|
+
await iterator.return()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Collect values into batches of specified size.
|
|
329
|
+
*
|
|
330
|
+
* @throws {Error} If size is not positive
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* ```typescript
|
|
334
|
+
* const batched = batch(events, 10) // Emit arrays of 10 events
|
|
335
|
+
* ```
|
|
336
|
+
*/
|
|
337
|
+
export function batch<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[], void, undefined> {
|
|
338
|
+
if (size <= 0) throw new Error("Batch size must be positive")
|
|
339
|
+
return batchImpl(source, size)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function* batchImpl<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[], void, undefined> {
|
|
343
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
344
|
+
let buffer: T[] = []
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
for await (const value of { [Symbol.asyncIterator]: () => iterator }) {
|
|
348
|
+
buffer.push(value)
|
|
349
|
+
if (buffer.length >= size) {
|
|
350
|
+
yield buffer
|
|
351
|
+
buffer = []
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Emit remaining items
|
|
355
|
+
if (buffer.length > 0) {
|
|
356
|
+
yield buffer
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
if (iterator.return) {
|
|
360
|
+
await iterator.return()
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Concatenate multiple AsyncIterables in sequence.
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* ```typescript
|
|
370
|
+
* const all = concat(header, body, footer)
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export async function* concat<T>(...sources: AsyncIterable<T>[]): AsyncGenerator<T, void, undefined> {
|
|
374
|
+
for (const source of sources) {
|
|
375
|
+
yield* source
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Zip multiple AsyncIterables together.
|
|
381
|
+
* Completes when the shortest source completes.
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* ```typescript
|
|
385
|
+
* const pairs = zip(keys, timestamps) // [key, timestamp][]
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
export async function* zip<T extends unknown[]>(
|
|
389
|
+
...sources: { [K in keyof T]: AsyncIterable<T[K]> }
|
|
390
|
+
): AsyncGenerator<T, void, undefined> {
|
|
391
|
+
const iterators = sources.map((source) => source[Symbol.asyncIterator]())
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
while (true) {
|
|
395
|
+
const results = await Promise.all(iterators.map((it) => it.next()))
|
|
396
|
+
|
|
397
|
+
// If any source is done, we're done
|
|
398
|
+
if (results.some((r) => r.done)) break
|
|
399
|
+
|
|
400
|
+
yield results.map((r) => r.value) as T
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
await Promise.all(iterators.map((it) => (it.return ? it.return() : Promise.resolve())))
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# silvery/tea
|
|
2
|
+
|
|
3
|
+
Zustand middleware for TEA (The Elm Architecture) effects-as-data pattern.
|
|
4
|
+
|
|
5
|
+
A ~30-line middleware that lets Zustand reducers optionally return `[state, effects]`
|
|
6
|
+
alongside plain state. Gradual adoption: start with pure state updates (Level 3),
|
|
7
|
+
add effects-as-data when you need side effects (Level 4).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { tea, collect } from "@silvery/tea"
|
|
13
|
+
import type { TeaResult, TeaReducer, EffectRunners, TeaSlice, EffectLike } from "@silvery/tea"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Not a standalone package. Exported as a sub-path from Silvery.
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
Level 3 — ops as data. The reducer takes state and an operation, returns new state.
|
|
21
|
+
`tea()` wraps it as a Zustand state creator with `dispatch`.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createStore } from "zustand"
|
|
25
|
+
import { tea } from "@silvery/tea"
|
|
26
|
+
|
|
27
|
+
interface State {
|
|
28
|
+
count: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Op = { type: "increment" } | { type: "decrement" } | { type: "reset" }
|
|
32
|
+
|
|
33
|
+
function reducer(state: State, op: Op): State {
|
|
34
|
+
switch (op.type) {
|
|
35
|
+
case "increment":
|
|
36
|
+
return { ...state, count: state.count + 1 }
|
|
37
|
+
case "decrement":
|
|
38
|
+
return { ...state, count: state.count - 1 }
|
|
39
|
+
case "reset":
|
|
40
|
+
return { ...state, count: 0 }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const store = createStore(tea({ count: 0 }, reducer))
|
|
45
|
+
|
|
46
|
+
store.getState().dispatch({ type: "increment" })
|
|
47
|
+
store.getState().count // 1
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
No effects, no runners, no ceremony. Plain `(state, op) => state`.
|
|
51
|
+
|
|
52
|
+
## Effects
|
|
53
|
+
|
|
54
|
+
Level 4 — effects as data. Same middleware, same reducer signature. When an operation
|
|
55
|
+
needs a side effect, return `[state, effects]` instead of plain state. Mix freely
|
|
56
|
+
on a per-case basis.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { createStore } from "zustand"
|
|
60
|
+
import { tea, type TeaResult, type EffectRunners } from "@silvery/tea"
|
|
61
|
+
|
|
62
|
+
// Effects are plain objects with a `type` discriminant
|
|
63
|
+
const log = (msg: string) => ({ type: "log" as const, msg })
|
|
64
|
+
const save = (url: string, body: unknown) => ({ type: "save" as const, url, body })
|
|
65
|
+
|
|
66
|
+
type MyEffect = ReturnType<typeof log> | ReturnType<typeof save>
|
|
67
|
+
|
|
68
|
+
interface State {
|
|
69
|
+
count: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Op = { type: "increment" } | { type: "save" }
|
|
73
|
+
|
|
74
|
+
function reducer(state: State, op: Op): TeaResult<State, MyEffect> {
|
|
75
|
+
switch (op.type) {
|
|
76
|
+
case "increment":
|
|
77
|
+
return { ...state, count: state.count + 1 } // Level 3: plain state
|
|
78
|
+
case "save":
|
|
79
|
+
return [state, [save("/api/count", state), log("saved")]] // Level 4: [state, effects]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Effect runners: swappable for production, test, replay
|
|
84
|
+
const runners: EffectRunners<MyEffect, Op> = {
|
|
85
|
+
log: (effect) => console.log(effect.msg),
|
|
86
|
+
save: async (effect, dispatch) => {
|
|
87
|
+
await fetch(effect.url, { method: "POST", body: JSON.stringify(effect.body) })
|
|
88
|
+
// Round-trip: dispatch back into the reducer (Elm's Cmd Msg pattern)
|
|
89
|
+
dispatch({ type: "increment" })
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const store = createStore(tea({ count: 0 }, reducer, { runners }))
|
|
94
|
+
|
|
95
|
+
store.getState().dispatch({ type: "save" })
|
|
96
|
+
// -> state unchanged, effects executed: POST /api/count + console.log("saved")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Detection mechanism
|
|
100
|
+
|
|
101
|
+
`Array.isArray` distinguishes plain state (object) from `[state, effects]` (array).
|
|
102
|
+
Safe because Zustand state is always an object — never an array.
|
|
103
|
+
|
|
104
|
+
### Effect execution
|
|
105
|
+
|
|
106
|
+
Effects run synchronously after state update, in order. Each effect is routed to a
|
|
107
|
+
runner by its `type` field. Runners receive a `dispatch` callback for round-trip
|
|
108
|
+
communication. Unmatched effects (no runner for that type) are silently dropped.
|
|
109
|
+
|
|
110
|
+
## API Reference
|
|
111
|
+
|
|
112
|
+
### `tea(initialState, reducer, options?)`
|
|
113
|
+
|
|
114
|
+
Zustand `StateCreator` middleware. Returns a store shape of `S & { dispatch }`.
|
|
115
|
+
|
|
116
|
+
| Parameter | Type | Description |
|
|
117
|
+
| -------------- | ---------------------- | -------------------------------------------------------- |
|
|
118
|
+
| `initialState` | `S extends object` | Initial domain state |
|
|
119
|
+
| `reducer` | `TeaReducer<S, Op, E>` | Pure reducer: `(state, op) => state \| [state, effects]` |
|
|
120
|
+
| `options` | `TeaOptions<E, Op>` | Optional `{ runners }` for effect execution |
|
|
121
|
+
|
|
122
|
+
Returns: `StateCreator<TeaSlice<S, Op>>`
|
|
123
|
+
|
|
124
|
+
### `collect(result)`
|
|
125
|
+
|
|
126
|
+
Test helper. Normalizes a reducer result to `[state, effects]` regardless of what
|
|
127
|
+
the reducer returned.
|
|
128
|
+
|
|
129
|
+
| Parameter | Type | Description |
|
|
130
|
+
| --------- | ----------------- | -------------------------------- |
|
|
131
|
+
| `result` | `TeaResult<S, E>` | Return value from a reducer call |
|
|
132
|
+
|
|
133
|
+
Returns: `[S, E[]]` — always a tuple. Plain state becomes `[state, []]`.
|
|
134
|
+
|
|
135
|
+
### Types
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// An effect must have a `type` discriminant
|
|
139
|
+
type EffectLike = { type: string }
|
|
140
|
+
|
|
141
|
+
// Reducer return: plain state (no effects) or [state, effects]
|
|
142
|
+
type TeaResult<S, E extends EffectLike = EffectLike> = S | readonly [S, E[]]
|
|
143
|
+
|
|
144
|
+
// A reducer function
|
|
145
|
+
type TeaReducer<S, Op, E extends EffectLike = EffectLike> = (state: S, op: Op) => TeaResult<S, E>
|
|
146
|
+
|
|
147
|
+
// Runners keyed by effect type. Each receives the effect + dispatch for round-trips.
|
|
148
|
+
type EffectRunners<E extends EffectLike, Op = unknown> = {
|
|
149
|
+
[K in E["type"]]?: (effect: Extract<E, { type: K }>, dispatch: (op: Op) => void) => void | Promise<void>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Options for tea()
|
|
153
|
+
interface TeaOptions<E extends EffectLike, Op> {
|
|
154
|
+
runners?: EffectRunners<E, Op>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The store shape: domain state + dispatch
|
|
158
|
+
type TeaSlice<S, Op> = S & { dispatch: (op: Op) => void }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Testing
|
|
162
|
+
|
|
163
|
+
Reducers are pure functions. Test them directly without a store. `collect()` normalizes
|
|
164
|
+
the return value so assertions work uniformly whether the reducer returned plain state
|
|
165
|
+
or a tuple.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { collect } from "@silvery/tea"
|
|
169
|
+
|
|
170
|
+
const initial: State = { count: 0 }
|
|
171
|
+
|
|
172
|
+
// Level 3 case: plain state
|
|
173
|
+
const [state1, effects1] = collect(reducer(initial, { type: "increment" }))
|
|
174
|
+
expect(state1.count).toBe(1)
|
|
175
|
+
expect(effects1).toEqual([])
|
|
176
|
+
|
|
177
|
+
// Level 4 case: state + effects
|
|
178
|
+
const [state2, effects2] = collect(reducer(initial, { type: "save" }))
|
|
179
|
+
expect(state2).toEqual(initial)
|
|
180
|
+
expect(effects2).toContainEqual(save("/api/count", initial))
|
|
181
|
+
expect(effects2).toContainEqual(log("saved"))
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Effect runners are tested separately — inject mock dispatch, assert on calls:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const dispatched: Op[] = []
|
|
188
|
+
const mockDispatch = (op: Op) => dispatched.push(op)
|
|
189
|
+
|
|
190
|
+
runners.save!(save("/api/count", { count: 5 }), mockDispatch)
|
|
191
|
+
// assert: dispatched contains expected round-trip ops
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Prior Art
|
|
195
|
+
|
|
196
|
+
| System | Approach | Difference |
|
|
197
|
+
| ------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------- |
|
|
198
|
+
| [redux-loop](https://github.com/redux-loop/redux-loop) | Redux middleware, `loop(state, effects)` | Store enhancer, more API surface. tea() is ~30 lines. |
|
|
199
|
+
| [Hyperapp v2](https://github.com/jorgebucaran/hyperapp) | `[state, effects]` tuples from actions | Full framework. tea() is just a Zustand middleware. |
|
|
200
|
+
| [Elm](https://guide.elm-lang.org/effects/) | `Cmd Msg` — effects return messages to update | The original. tea() adapts this to JS/Zustand. |
|
|
201
|
+
|
|
202
|
+
The key insight shared by all: effects are **data**, not imperative calls. The reducer
|
|
203
|
+
declares _what_ should happen; runners decide _how_. This makes reducers pure, testable,
|
|
204
|
+
and replayable.
|
|
205
|
+
|
|
206
|
+
## See Also
|
|
207
|
+
|
|
208
|
+
- [docs/guide/state-management.md](../../docs/guide/state-management.md) — full state management guide covering createApp, createSlice, selectors, and effects middleware
|