@sladg/apex-state 3.4.0 → 3.4.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.
- package/README.md +447 -96
- package/dist/apex_state_wasm-WOOTVUVC.js +674 -0
- package/dist/apex_state_wasm-WOOTVUVC.js.map +1 -0
- package/dist/chunk-SLJZLVMQ.js +2390 -0
- package/dist/chunk-SLJZLVMQ.js.map +1 -0
- package/dist/chunk-VR6T6OJS.js +26 -0
- package/dist/chunk-VR6T6OJS.js.map +1 -0
- package/dist/index.d.ts +19 -1
- package/dist/index.js +25 -3053
- package/dist/index.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +38 -2022
- package/dist/testing/index.js.map +1 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -2,11 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
Reactive state management for React built on [Valtio](https://github.com/pmndrs/valtio). Declare what your fields need — validation, conditional UI, sync, listeners — and the store handles the rest. Optional Rust/WASM accelerator for complex workloads (up to 367x faster).
|
|
4
4
|
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
5
7
|
```bash
|
|
6
8
|
npm install @sladg/apex-state valtio zod react
|
|
7
9
|
```
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
```tsx
|
|
12
|
+
import { createGenericStore } from '@sladg/apex-state'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
|
|
15
|
+
// 1. Define your state shape
|
|
16
|
+
type FormState = {
|
|
17
|
+
user: { name: string; email: string; age: number }
|
|
18
|
+
preferences: { newsletter: boolean; theme: 'light' | 'dark' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 2. Create a typed store (WASM-accelerated by default)
|
|
22
|
+
const store = createGenericStore<FormState>()
|
|
23
|
+
|
|
24
|
+
// 3. Wrap your app with Provider
|
|
25
|
+
const App = () => (
|
|
26
|
+
<store.Provider initialState={{
|
|
27
|
+
user: { name: '', email: '', age: 0 },
|
|
28
|
+
preferences: { newsletter: false, theme: 'light' },
|
|
29
|
+
}}>
|
|
30
|
+
<UserForm />
|
|
31
|
+
</store.Provider>
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// 4. Use hooks to read/write state and declare concerns
|
|
35
|
+
const UserForm = () => {
|
|
36
|
+
store.useConcerns('user-form', {
|
|
37
|
+
'user.email': {
|
|
38
|
+
validationState: { schema: z.string().email('Invalid email') },
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const { value, setValue, validationState } = store.useFieldStore('user.email')
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div>
|
|
46
|
+
<input
|
|
47
|
+
value={value}
|
|
48
|
+
onChange={(e) => setValue(e.target.value)}
|
|
49
|
+
className={validationState?.isError ? 'error' : ''}
|
|
50
|
+
/>
|
|
51
|
+
{validationState?.isError && <span>{validationState.errors[0]}</span>}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
| Feature | Description | Details |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| **Type-safe paths** | `DeepKey<T>` / `DeepValue<T, P>` — compile-time path safety | |
|
|
62
|
+
| **Concerns** | Validation (Zod), BoolLogic conditions, dynamic text | [Concerns Guide](docs/guides/CONCERNS_GUIDE.md) |
|
|
63
|
+
| **Side effects** | Sync paths, flip paths, aggregations, listeners | [Side Effects Guide](docs/SIDE_EFFECTS_GUIDE.md) |
|
|
64
|
+
| **WASM mode** | Rust-powered pipeline for bulk operations (up to 367x faster) | [Architecture](docs/WASM_ARCHITECTURE.md) |
|
|
65
|
+
| **Composable hooks** | Buffered, throttled, transformed field wrappers | [Store & Hooks](docs/guides/STORE_HOOKS.md) |
|
|
66
|
+
| **Record/wildcard** | `Record<string, V>` with `_()` hash key paths | [Wildcard Guide](docs/WILD_FUNCTION_GUIDE.md) |
|
|
67
|
+
| **Testing mock** | Drop-in `vi.mock` replacement with call tracking | [Testing Mock](docs/TESTING_MOCK.md) |
|
|
68
|
+
|
|
69
|
+
## Full Example
|
|
10
70
|
|
|
11
71
|
```tsx
|
|
12
72
|
import { createGenericStore } from '@sladg/apex-state'
|
|
@@ -14,7 +74,7 @@ import { z } from 'zod'
|
|
|
14
74
|
|
|
15
75
|
type OrderState = {
|
|
16
76
|
product: { name: string; quantity: number; price: number }
|
|
17
|
-
shipping: { address: string; express: boolean }
|
|
77
|
+
shipping: { address: string; express: boolean; standard: boolean }
|
|
18
78
|
payment: { method: 'card' | 'cash'; cardNumber: string }
|
|
19
79
|
status: 'draft' | 'submitted'
|
|
20
80
|
}
|
|
@@ -22,21 +82,20 @@ type OrderState = {
|
|
|
22
82
|
const store = createGenericStore<OrderState>()
|
|
23
83
|
|
|
24
84
|
const OrderForm = () => {
|
|
25
|
-
//
|
|
85
|
+
// Side effects: auto-flip booleans
|
|
26
86
|
store.useSideEffects('order', {
|
|
27
|
-
syncPaths: [['product.price', 'shipping.basePrice']],
|
|
28
87
|
flipPaths: [['shipping.express', 'shipping.standard']],
|
|
29
88
|
})
|
|
30
89
|
|
|
31
|
-
//
|
|
90
|
+
// Concerns: declarative validation and conditional UI
|
|
32
91
|
store.useConcerns('order', {
|
|
33
92
|
'product.quantity': {
|
|
34
93
|
validationState: { schema: z.number().min(1).max(100) },
|
|
35
|
-
disabledWhen: {
|
|
94
|
+
disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'submitted'] } },
|
|
36
95
|
},
|
|
37
96
|
'payment.cardNumber': {
|
|
38
97
|
validationState: { schema: z.string().regex(/^\d{16}$/) },
|
|
39
|
-
visibleWhen: {
|
|
98
|
+
visibleWhen: { boolLogic: { IS_EQUAL: ['payment.method', 'card'] } },
|
|
40
99
|
},
|
|
41
100
|
})
|
|
42
101
|
|
|
@@ -57,7 +116,7 @@ const OrderForm = () => {
|
|
|
57
116
|
const App = () => (
|
|
58
117
|
<store.Provider initialState={{
|
|
59
118
|
product: { name: 'Widget', quantity: 1, price: 29.99 },
|
|
60
|
-
shipping: { address: '', express: false },
|
|
119
|
+
shipping: { address: '', express: false, standard: true },
|
|
61
120
|
payment: { method: 'card', cardNumber: '' },
|
|
62
121
|
status: 'draft',
|
|
63
122
|
}}>
|
|
@@ -66,123 +125,416 @@ const App = () => (
|
|
|
66
125
|
)
|
|
67
126
|
```
|
|
68
127
|
|
|
69
|
-
##
|
|
128
|
+
## Reading and Writing State
|
|
70
129
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
| **Type-safe paths** | `DeepKey<T>` / `DeepValue<T, P>` — compile-time path safety | |
|
|
74
|
-
| **Concerns** | Validation, BoolLogic conditions, dynamic text | [Concerns Guide](docs/guides/CONCERNS_GUIDE.md) |
|
|
75
|
-
| **Side effects** | Sync paths, flip paths, aggregations, listeners | [Side Effects Guide](docs/SIDE_EFFECTS_GUIDE.md) |
|
|
76
|
-
| **WASM mode** | Rust-powered pipeline for bulk operations | [Architecture](docs/WASM_ARCHITECTURE.md) |
|
|
77
|
-
| **Composable hooks** | Buffered, throttled, transformed field wrappers | [Store & Hooks](docs/guides/STORE_HOOKS.md) |
|
|
78
|
-
| **Record/wildcard** | `Record<string, V>` with `[*]` wildcard paths | [Wildcard Guide](docs/WILD_FUNCTION_GUIDE.md) |
|
|
79
|
-
| **Testing mock** | Drop-in `vi.mock` replacement with call tracking | [Testing Mock](docs/TESTING_MOCK.md) |
|
|
130
|
+
```tsx
|
|
131
|
+
const store = createGenericStore<MyState>()
|
|
80
132
|
|
|
81
|
-
|
|
133
|
+
// useStore — simple [value, setter] tuple (like useState)
|
|
134
|
+
const NameInput = () => {
|
|
135
|
+
const [name, setName] = store.useStore('user.name')
|
|
136
|
+
return <input value={name} onChange={(e) => setName(e.target.value)} />
|
|
137
|
+
}
|
|
82
138
|
|
|
139
|
+
// useFieldStore — object API with concerns merged in
|
|
140
|
+
const EmailInput = () => {
|
|
141
|
+
const { value, setValue, validationState, disabledWhen } =
|
|
142
|
+
store.useFieldStore('user.email')
|
|
143
|
+
return (
|
|
144
|
+
<input
|
|
145
|
+
value={value}
|
|
146
|
+
onChange={(e) => setValue(e.target.value)}
|
|
147
|
+
disabled={disabledWhen}
|
|
148
|
+
className={validationState?.isError ? 'error' : ''}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// useJitStore — bulk operations and non-reactive reads
|
|
154
|
+
const ImportButton = () => {
|
|
155
|
+
const { setChanges, getState } = store.useJitStore()
|
|
156
|
+
|
|
157
|
+
const handleImport = (data: Record<string, unknown>) => {
|
|
158
|
+
setChanges([
|
|
159
|
+
['user.name', data.name, {}],
|
|
160
|
+
['user.email', data.email, {}],
|
|
161
|
+
['user.age', data.age, {}],
|
|
162
|
+
])
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return <button onClick={() => handleImport({ name: 'Alice', email: 'a@b.com', age: 30 })}>Import</button>
|
|
166
|
+
}
|
|
83
167
|
```
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
168
|
+
|
|
169
|
+
## Validation with Zod
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
import { createGenericStore } from '@sladg/apex-state'
|
|
173
|
+
import { z } from 'zod'
|
|
174
|
+
|
|
175
|
+
type ProfileState = {
|
|
176
|
+
user: { name: string; email: string; age: number; bio: string }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const store = createGenericStore<ProfileState>()
|
|
180
|
+
|
|
181
|
+
const ProfileForm = () => {
|
|
182
|
+
// Register validation schemas for multiple fields at once
|
|
183
|
+
store.useConcerns('profile-validation', {
|
|
184
|
+
'user.name': {
|
|
185
|
+
validationState: { schema: z.string().min(2, 'Name too short').max(50) },
|
|
186
|
+
},
|
|
187
|
+
'user.email': {
|
|
188
|
+
validationState: { schema: z.string().email('Enter a valid email') },
|
|
189
|
+
},
|
|
190
|
+
'user.age': {
|
|
191
|
+
validationState: { schema: z.number().min(18, 'Must be 18+').max(120) },
|
|
192
|
+
},
|
|
193
|
+
'user.bio': {
|
|
194
|
+
validationState: { schema: z.string().max(500, 'Bio too long') },
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const email = store.useFieldStore('user.email')
|
|
199
|
+
const age = store.useFieldStore('user.age')
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<form>
|
|
203
|
+
<div>
|
|
204
|
+
<input value={email.value} onChange={(e) => email.setValue(e.target.value)} />
|
|
205
|
+
{email.validationState?.isError && (
|
|
206
|
+
<ul>{email.validationState.errors.map((err, i) => <li key={i}>{err}</li>)}</ul>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<input
|
|
211
|
+
type="number"
|
|
212
|
+
value={age.value}
|
|
213
|
+
onChange={(e) => age.setValue(Number(e.target.value))}
|
|
214
|
+
/>
|
|
215
|
+
{age.validationState?.isError && <span>{age.validationState.errors[0]}</span>}
|
|
216
|
+
</div>
|
|
217
|
+
</form>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
98
220
|
```
|
|
99
221
|
|
|
100
|
-
|
|
222
|
+
## Conditional UI with BoolLogic
|
|
101
223
|
|
|
102
|
-
|
|
224
|
+
```tsx
|
|
225
|
+
store.useConcerns('conditional-ui', {
|
|
226
|
+
// Disable when another field has a specific value
|
|
227
|
+
'user.email': {
|
|
228
|
+
disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'submitted'] } },
|
|
229
|
+
},
|
|
103
230
|
|
|
104
|
-
|
|
231
|
+
// Show only when multiple conditions are true
|
|
232
|
+
'payment.cardNumber': {
|
|
233
|
+
visibleWhen: {
|
|
234
|
+
boolLogic: {
|
|
235
|
+
AND: [
|
|
236
|
+
{ IS_EQUAL: ['payment.method', 'card'] },
|
|
237
|
+
{ EXISTS: 'user.email' },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
105
242
|
|
|
106
|
-
|
|
243
|
+
// Make readonly when value exceeds threshold
|
|
244
|
+
'order.total': {
|
|
245
|
+
readonlyWhen: { boolLogic: { GT: ['order.total', 10000] } },
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// Complex nested logic with OR, NOT
|
|
249
|
+
'shipping.express': {
|
|
250
|
+
disabledWhen: {
|
|
251
|
+
boolLogic: {
|
|
252
|
+
OR: [
|
|
253
|
+
{ IS_EQUAL: ['status', 'shipped'] },
|
|
254
|
+
{ NOT: { EXISTS: 'shipping.address' } },
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// Available BoolLogic operators:
|
|
262
|
+
// IS_EQUAL, EXISTS, IS_EMPTY, GT, LT, GTE, LTE, IN, AND, OR, NOT
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Side Effects
|
|
266
|
+
|
|
267
|
+
### Sync Paths
|
|
107
268
|
|
|
108
269
|
```tsx
|
|
109
|
-
|
|
110
|
-
|
|
270
|
+
store.useSideEffects('sync', {
|
|
271
|
+
syncPaths: [
|
|
272
|
+
['billing.email', 'shipping.email'], // changing one updates the other
|
|
273
|
+
['billing.phone', 'shipping.phone'],
|
|
274
|
+
],
|
|
275
|
+
})
|
|
276
|
+
// When user types in billing.email, shipping.email updates automatically
|
|
111
277
|
```
|
|
112
278
|
|
|
113
|
-
###
|
|
279
|
+
### Flip Paths
|
|
114
280
|
|
|
115
|
-
|
|
281
|
+
```tsx
|
|
282
|
+
store.useSideEffects('flips', {
|
|
283
|
+
flipPaths: [
|
|
284
|
+
['isActive', 'isInactive'], // setting isActive=true -> isInactive=false
|
|
285
|
+
['isExpanded', 'isCollapsed'],
|
|
286
|
+
],
|
|
287
|
+
})
|
|
288
|
+
```
|
|
116
289
|
|
|
117
|
-
|
|
118
|
-
|---|---|---|---|
|
|
119
|
-
| Single field edit | **0.5us** | 1.4us | Legacy 2.6x |
|
|
120
|
-
| 7 changes + cascading listeners | 41.8ms | **0.11ms** | WASM 367x |
|
|
121
|
-
| 60 bulk price changes | 596ms | **2.9ms** | WASM 207x |
|
|
122
|
-
| 135 changes (full catalog refresh) | 621ms | **2.99ms** | WASM 208x |
|
|
290
|
+
### Aggregations
|
|
123
291
|
|
|
124
|
-
|
|
292
|
+
```tsx
|
|
293
|
+
store.useSideEffects('agg', {
|
|
294
|
+
aggregations: [
|
|
295
|
+
// Target is ALWAYS first. Multiple pairs with same target form a group.
|
|
296
|
+
['summary.price', 'legs.0.price'],
|
|
297
|
+
['summary.price', 'legs.1.price'],
|
|
298
|
+
['summary.price', 'legs.2.price'],
|
|
299
|
+
],
|
|
300
|
+
})
|
|
301
|
+
// summary.price = leg price if ALL legs match, undefined if they differ
|
|
302
|
+
```
|
|
125
303
|
|
|
126
|
-
###
|
|
304
|
+
### Listeners
|
|
127
305
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
306
|
+
```tsx
|
|
307
|
+
store.useSideEffects('listeners', {
|
|
308
|
+
listeners: [
|
|
309
|
+
{
|
|
310
|
+
path: 'user.profile', // watch changes under this path
|
|
311
|
+
scope: 'user.profile', // receive scoped state and relative paths
|
|
312
|
+
fn: (changes, state) => {
|
|
313
|
+
// changes: [['name', 'Alice', {}]] — paths relative to scope
|
|
314
|
+
// state: { name: 'Alice', email: '...' } — user.profile sub-object
|
|
315
|
+
return [['audit.lastEdit', Date.now(), {}]] // return FULL paths for new changes
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
})
|
|
320
|
+
```
|
|
132
321
|
|
|
133
|
-
|
|
322
|
+
## Dynamic Text
|
|
134
323
|
|
|
135
|
-
|
|
324
|
+
```tsx
|
|
325
|
+
store.useConcerns('dynamic-text', {
|
|
326
|
+
'legs.0.strike': {
|
|
327
|
+
dynamicTooltip: { template: 'Current strike: {{legs.0.strike}}' },
|
|
328
|
+
dynamicLabel: { template: 'Strike for {{legs.0.product}}' },
|
|
329
|
+
dynamicPlaceholder: { template: 'Enter value (min {{legs.0.minStrike}})' },
|
|
330
|
+
},
|
|
331
|
+
})
|
|
136
332
|
|
|
137
|
-
|
|
333
|
+
const { dynamicTooltip, dynamicLabel } = store.useFieldConcerns('legs.0.strike')
|
|
334
|
+
// dynamicTooltip -> "Current strike: 105"
|
|
335
|
+
// dynamicLabel -> "Strike for AAPL"
|
|
336
|
+
```
|
|
138
337
|
|
|
139
|
-
|
|
338
|
+
## Composable Field Hooks
|
|
140
339
|
|
|
141
340
|
```tsx
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
341
|
+
import { useBufferedField, useThrottledField, useTransformedField } from '@sladg/apex-state'
|
|
342
|
+
|
|
343
|
+
// Buffer edits locally, commit/cancel explicitly
|
|
344
|
+
const PriceEditor = () => {
|
|
345
|
+
const raw = store.useFieldStore('product.price')
|
|
346
|
+
const buffered = useBufferedField(raw)
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div>
|
|
350
|
+
<input value={buffered.value} onChange={(e) => buffered.setValue(Number(e.target.value))} />
|
|
351
|
+
<button onClick={buffered.commit} disabled={!buffered.isDirty}>Save</button>
|
|
352
|
+
<button onClick={buffered.cancel}>Cancel</button>
|
|
353
|
+
</div>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Throttle rapid setValue calls (e.g., slider input)
|
|
358
|
+
const VolumeSlider = () => {
|
|
359
|
+
const raw = store.useFieldStore('audio.volume')
|
|
360
|
+
const throttled = useThrottledField(raw, { ms: 100 })
|
|
361
|
+
return <input type="range" value={throttled.value} onChange={(e) => throttled.setValue(Number(e.target.value))} />
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Transform display format (cents <-> dollars)
|
|
365
|
+
const CurrencyInput = () => {
|
|
366
|
+
const raw = store.useFieldStore('price')
|
|
367
|
+
const formatted = useTransformedField(raw, {
|
|
368
|
+
to: (cents: number) => (cents / 100).toFixed(2), // store -> display
|
|
369
|
+
from: (dollars: string) => Math.round(parseFloat(dollars) * 100), // display -> store
|
|
370
|
+
})
|
|
371
|
+
return <input value={formatted.value} onChange={(e) => formatted.setValue(e.target.value)} />
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Chain them: buffered + transformed
|
|
375
|
+
const raw = store.useFieldStore('price')
|
|
376
|
+
const buffered = useBufferedField(raw)
|
|
377
|
+
const display = useTransformedField(buffered, {
|
|
378
|
+
to: (cents) => (cents / 100).toFixed(2),
|
|
379
|
+
from: (dollars) => Math.round(parseFloat(dollars) * 100),
|
|
380
|
+
})
|
|
381
|
+
// display has: value, setValue, commit, cancel, isDirty
|
|
151
382
|
```
|
|
152
383
|
|
|
153
|
-
|
|
384
|
+
## Hash Key Paths for Record Types
|
|
154
385
|
|
|
155
386
|
```tsx
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
387
|
+
import { _ } from '@sladg/apex-state'
|
|
388
|
+
|
|
389
|
+
// _() marks a segment as a hash key for Record-typed paths
|
|
390
|
+
const strikePath = `portfolio.legs.${_('l1')}.strike`
|
|
391
|
+
// -> typed string containing HASH_KEY marker
|
|
392
|
+
|
|
393
|
+
// Use with concerns — applies to ALL keys in the Record
|
|
394
|
+
store.useConcerns('wildcards', {
|
|
395
|
+
[strikePath]: {
|
|
396
|
+
validationState: { schema: z.number().min(0) },
|
|
397
|
+
disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'locked'] } },
|
|
161
398
|
},
|
|
162
399
|
})
|
|
400
|
+
|
|
401
|
+
// Multiple hash keys for deeply nested Records
|
|
402
|
+
const nestedPath = `books.${_('b1')}.products.${_('p1')}.legs.${_('l1')}.notional`
|
|
163
403
|
```
|
|
164
404
|
|
|
165
|
-
|
|
405
|
+
## Testing with the Mock Module
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
// __mocks__/@sladg/apex-state.ts
|
|
409
|
+
export * from '@sladg/apex-state/testing'
|
|
166
410
|
|
|
167
|
-
|
|
411
|
+
// your-test.test.ts
|
|
412
|
+
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|
413
|
+
import { __mocked, createGenericStore } from '@sladg/apex-state/testing'
|
|
414
|
+
import { renderHook } from '@testing-library/react'
|
|
168
415
|
|
|
169
|
-
|
|
416
|
+
vi.mock('@sladg/apex-state')
|
|
170
417
|
|
|
171
|
-
|
|
418
|
+
type FormState = { user: { email: string; name: string }; count: number }
|
|
172
419
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
420
|
+
beforeEach(() => __mocked.reset())
|
|
421
|
+
|
|
422
|
+
it('seeds state with type-safe chaining', () => {
|
|
423
|
+
__mocked
|
|
424
|
+
.set<FormState>({ user: { email: '', name: '' }, count: 0 })
|
|
425
|
+
.set('user.email', 'alice@test.com')
|
|
426
|
+
.set('count', 42)
|
|
427
|
+
|
|
428
|
+
expect(__mocked.getState()).toEqual({
|
|
429
|
+
user: { email: 'alice@test.com', name: '' },
|
|
430
|
+
count: 42,
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('tracks setValue calls from hooks', () => {
|
|
435
|
+
const store = createGenericStore<FormState>()
|
|
436
|
+
const hook = renderHook(() => store.useStore('user.email'))
|
|
437
|
+
|
|
438
|
+
hook.result.current[1]('bob@test.com')
|
|
439
|
+
|
|
440
|
+
expect(__mocked.state.calls).toContainEqual({
|
|
441
|
+
path: 'user.email',
|
|
442
|
+
value: 'bob@test.com',
|
|
443
|
+
meta: undefined,
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('tracks concern and side effect registrations', () => {
|
|
448
|
+
const store = createGenericStore<FormState>()
|
|
449
|
+
store.useConcerns('form', { 'user.email': { disabledWhen: { boolLogic: { AND: [] } } } })
|
|
450
|
+
store.useSideEffects('effects', { listeners: [] })
|
|
451
|
+
|
|
452
|
+
expect(__mocked.state.effects).toHaveLength(2)
|
|
453
|
+
expect(__mocked.state.effects[0]?.type).toBe('concerns')
|
|
454
|
+
expect(__mocked.state.effects[1]?.type).toBe('sideEffects')
|
|
182
455
|
})
|
|
183
456
|
```
|
|
184
457
|
|
|
185
|
-
|
|
458
|
+
## WASM vs Legacy Mode
|
|
459
|
+
|
|
460
|
+
WASM is the default. Pass `{ useLegacyImplementation: true }` for pure JS:
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
// WASM (default) — Rust-powered pipeline, faster for complex state
|
|
464
|
+
const wasmStore = createGenericStore<MyState>()
|
|
465
|
+
|
|
466
|
+
// Legacy JS — pure JavaScript, no WASM binary needed
|
|
467
|
+
const legacyStore = createGenericStore<MyState>({ useLegacyImplementation: true })
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Performance
|
|
471
|
+
|
|
472
|
+
Benchmarked with 60 variants across 3 Record layers, 75 syncs, 40 flips, 100 BoolLogic conditions, 85 listeners:
|
|
473
|
+
|
|
474
|
+
| Operation | Legacy | WASM | Winner |
|
|
475
|
+
|---|---|---|---|
|
|
476
|
+
| Single field edit | **0.5us** | 1.4us | Legacy 2.6x |
|
|
477
|
+
| 7 changes + cascading listeners | 41.8ms | **0.11ms** | WASM 367x |
|
|
478
|
+
| 60 bulk price changes | 596ms | **2.9ms** | WASM 207x |
|
|
479
|
+
| 135 changes (full catalog refresh) | 621ms | **2.99ms** | WASM 208x |
|
|
480
|
+
|
|
481
|
+
Both modes produce **identical state** — verified across all 16 benchmark scenarios. See [Benchmark Comparison](docs/BENCHMARK_COMPARISON.md) for the full analysis.
|
|
482
|
+
|
|
483
|
+
## API Overview
|
|
484
|
+
|
|
485
|
+
### Store Creation
|
|
486
|
+
|
|
487
|
+
`createGenericStore<T>(config?)` returns all hooks:
|
|
488
|
+
|
|
489
|
+
| Hook | Returns | Use case |
|
|
490
|
+
|---|---|---|
|
|
491
|
+
| `Provider` | React context | Wraps component tree with `initialState` |
|
|
492
|
+
| `useStore(path)` | `[value, setValue]` | Simple read/write (like `useState`) |
|
|
493
|
+
| `useFieldStore(path)` | `{ value, setValue, ...concerns }` | Form fields with merged concerns |
|
|
494
|
+
| `useJitStore()` | `{ proxyValue, setChanges, getState }` | Bulk updates, non-reactive reads |
|
|
495
|
+
| `useConcerns(id, config)` | `void` | Register validation/BoolLogic/dynamic text |
|
|
496
|
+
| `useSideEffects(id, config)` | `void` | Register sync/flip/aggregation/listeners |
|
|
497
|
+
| `useFieldConcerns(path)` | `EvaluatedConcerns` | Read concern results for a path |
|
|
498
|
+
| `withConcerns(selection)` | `{ useFieldStore }` | Scoped field store with selected concerns |
|
|
499
|
+
|
|
500
|
+
### Built-in Concerns
|
|
501
|
+
|
|
502
|
+
| Concern | Returns | Config |
|
|
503
|
+
|---|---|---|
|
|
504
|
+
| `validationState` | `{ isError, errors[] }` | `{ schema: ZodSchema }` |
|
|
505
|
+
| `disabledWhen` | `boolean` | `{ boolLogic: BoolLogic }` |
|
|
506
|
+
| `visibleWhen` | `boolean` | `{ boolLogic: BoolLogic }` |
|
|
507
|
+
| `readonlyWhen` | `boolean` | `{ boolLogic: BoolLogic }` |
|
|
508
|
+
| `dynamicLabel` | `string` | `{ template: '{{path}}' }` |
|
|
509
|
+
| `dynamicTooltip` | `string` | `{ template: '{{path}}' }` |
|
|
510
|
+
| `dynamicPlaceholder` | `string` | `{ template: '{{path}}' }` |
|
|
511
|
+
|
|
512
|
+
### BoolLogic Operators
|
|
513
|
+
|
|
514
|
+
`IS_EQUAL`, `EXISTS`, `IS_EMPTY`, `GT`, `LT`, `GTE`, `LTE`, `IN`, `AND`, `OR`, `NOT`
|
|
515
|
+
|
|
516
|
+
## Architecture
|
|
517
|
+
|
|
518
|
+
```
|
|
519
|
+
setValue("email", "alice@example.com")
|
|
520
|
+
|
|
|
521
|
+
+--[WASM/Rust]--> shadow state + sync + flip + BoolLogic (Rust)
|
|
522
|
+
| |
|
|
523
|
+
| v
|
|
524
|
+
| execute listeners + Zod validators (JS)
|
|
525
|
+
| |
|
|
526
|
+
| v
|
|
527
|
+
| pipelineFinalize -> diff -> final changes (Rust)
|
|
528
|
+
|
|
|
529
|
+
+--[Legacy JS]--> sync -> flip -> listeners -> applyBatch
|
|
530
|
+
|
|
|
531
|
+
v
|
|
532
|
+
valtio proxy -> React re-render
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
**Dual-layer design:** JS/React owns reactivity and rendering. Rust/WASM owns heavy computation (graphs, diffing, pipeline orchestration). The boundary is thin: paths cross as strings, values as JSON. WASM decides the execution plan, JS executes user functions.
|
|
536
|
+
|
|
537
|
+
See [WASM Architecture](docs/WASM_ARCHITECTURE.md) for the full specification.
|
|
186
538
|
|
|
187
539
|
## Development
|
|
188
540
|
|
|
@@ -198,7 +550,6 @@ npm run wasm:check # Rust lint + check
|
|
|
198
550
|
### WASM Prerequisites
|
|
199
551
|
|
|
200
552
|
```bash
|
|
201
|
-
# Rust toolchain
|
|
202
553
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|
203
554
|
rustup target add wasm32-unknown-unknown
|
|
204
555
|
cargo install wasm-pack
|
|
@@ -208,22 +559,22 @@ cargo install wasm-pack
|
|
|
208
559
|
|
|
209
560
|
| Document | Covers |
|
|
210
561
|
|---|---|
|
|
211
|
-
| [
|
|
212
|
-
| [Benchmark Comparison](docs/BENCHMARK_COMPARISON.md) | Legacy vs WASM performance with 16 scenarios |
|
|
562
|
+
| [Store & Hooks](docs/guides/STORE_HOOKS.md) | Hook reference, composable hooks, patterns |
|
|
213
563
|
| [Concerns Guide](docs/guides/CONCERNS_GUIDE.md) | Concern lifecycle, built-ins, custom concerns |
|
|
214
564
|
| [Side Effects Guide](docs/SIDE_EFFECTS_GUIDE.md) | Sync, flip, aggregation, listener API |
|
|
215
|
-
| [
|
|
216
|
-
| [
|
|
217
|
-
| [Wildcard Paths](docs/WILD_FUNCTION_GUIDE.md) | `
|
|
218
|
-
| [
|
|
565
|
+
| [WASM Architecture](docs/WASM_ARCHITECTURE.md) | JS/WASM boundary, data flow, ownership model |
|
|
566
|
+
| [Benchmark Comparison](docs/BENCHMARK_COMPARISON.md) | Legacy vs WASM across 16 scenarios |
|
|
567
|
+
| [Wildcard Paths](docs/WILD_FUNCTION_GUIDE.md) | `_()` hash key utility for Record types |
|
|
568
|
+
| [String Interpolation](docs/INTERPOLATION.md) | Template helpers for dynamic text concerns |
|
|
219
569
|
| [Testing Mock](docs/TESTING_MOCK.md) | Mock module for consumer tests (`vi.mock`) |
|
|
570
|
+
| [Record Migration](docs/RECORD_MIGRATION.md) | Migration patterns for dynamic Record types |
|
|
571
|
+
| [Debug Timing](docs/DEBUG_TIMING.md) | Performance debugging utilities |
|
|
220
572
|
| [Full Index](docs/README.md) | Complete documentation index |
|
|
221
573
|
|
|
222
574
|
## Roadmap
|
|
223
575
|
|
|
224
|
-
- **Aggregation modes** —
|
|
225
|
-
- **Nested sub-stores** —
|
|
226
|
-
- **Technical debt resolution** — See [TECHNICAL_DEBT.md](TECHNICAL_DEBT.md) for tracked items.
|
|
576
|
+
- **Aggregation modes** — Planned: `SUM`, `AVG`, `COUNT`, `MIN`, `MAX`, and custom reducer functions (currently consensus mode only).
|
|
577
|
+
- **Nested sub-stores** — Component-level state that participates in the parent's pipeline.
|
|
227
578
|
|
|
228
579
|
## License
|
|
229
580
|
|