@vanijs/vani 0.3.0 → 0.4.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/DOCS.md +517 -86
- package/README.md +28 -310
- package/dist/lib/index.d.mts +3 -3
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/jsx-dev-runtime.mjs +1 -1
- package/dist/lib/jsx-runtime.d.mts +1 -1
- package/dist/lib/jsx-runtime.mjs +1 -1
- package/dist/lib/{runtime-Dp-nlil7.d.mts → runtime-Cx9SKHrc.d.mts} +29 -5
- package/dist/lib/runtime-X8xzmXJz.mjs +1 -0
- package/package.json +39 -54
- package/dist/lib/runtime-BIk4OH1t.mjs +0 -1
package/DOCS.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
# Vani Documentation
|
|
1
|
+
# Vani Framework Documentation
|
|
2
2
|
|
|
3
3
|
Vani is a small, dependency‑free UI runtime built around a simple idea:
|
|
4
4
|
|
|
5
5
|
> Rendering should be explicit, local, and predictable.
|
|
6
6
|
|
|
7
|
-
Vani is **not** a Virtual DOM, not reactive‑by‑default
|
|
8
|
-
DOM subtree delimited by anchors and only update when you
|
|
7
|
+
Vani is **not** a Virtual DOM, not reactive‑by‑default (signals are opt‑in), and not
|
|
8
|
+
compiler‑driven. Components own a DOM subtree delimited by anchors and only update when you
|
|
9
|
+
explicitly ask them to.
|
|
9
10
|
|
|
10
11
|
---
|
|
11
12
|
|
|
@@ -13,8 +14,29 @@ DOM subtree delimited by anchors and only update when you explicitly ask them to
|
|
|
13
14
|
|
|
14
15
|
```bash
|
|
15
16
|
pnpm add @vanijs/vani
|
|
17
|
+
# or
|
|
18
|
+
npm install @vanijs/vani
|
|
19
|
+
# or
|
|
20
|
+
yarn add @vanijs/vani
|
|
21
|
+
# or
|
|
22
|
+
bun add @vanijs/vani
|
|
23
|
+
# or
|
|
24
|
+
deno add @vanijs/vani
|
|
16
25
|
```
|
|
17
26
|
|
|
27
|
+
## Install skills (AI commands)
|
|
28
|
+
|
|
29
|
+
Vani ships AI skills (commands) you can add to your agent tooling:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx add-skill itsjavi/vani
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Use documentation as a context
|
|
36
|
+
|
|
37
|
+
You can use the documentation as a context by feeding your agents the doc file directly, or by using
|
|
38
|
+
[Context7](https://context7.com/itsjavi/vani) to feed your agents the doc file.
|
|
39
|
+
|
|
18
40
|
---
|
|
19
41
|
|
|
20
42
|
## Quick Start (SPA)
|
|
@@ -42,7 +64,7 @@ const Counter = component((_, handle: Handle) => {
|
|
|
42
64
|
const appRoot = document.getElementById('app')
|
|
43
65
|
if (!appRoot) throw new Error('#app not found')
|
|
44
66
|
|
|
45
|
-
renderToDOM(
|
|
67
|
+
renderToDOM(Counter(), appRoot)
|
|
46
68
|
```
|
|
47
69
|
|
|
48
70
|
---
|
|
@@ -52,6 +74,17 @@ renderToDOM([Counter()], appRoot)
|
|
|
52
74
|
Vani is JS-first and transpiler-free, with an optional JSX adapter. JSX mode requires
|
|
53
75
|
`jsxImportSource` to be set to `@vanijs/vani` and a `.tsx` file.
|
|
54
76
|
|
|
77
|
+
TypeScript config example:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"compilerOptions": {
|
|
82
|
+
"jsx": "react-jsx",
|
|
83
|
+
"jsxImportSource": "@vanijs/vani"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
55
88
|
### 1) JSX counter button
|
|
56
89
|
|
|
57
90
|
```tsx
|
|
@@ -72,7 +105,7 @@ const Counter = component((_, handle: Handle) => {
|
|
|
72
105
|
)
|
|
73
106
|
})
|
|
74
107
|
|
|
75
|
-
renderToDOM(
|
|
108
|
+
renderToDOM(Counter(), document.getElementById('app')!)
|
|
76
109
|
```
|
|
77
110
|
|
|
78
111
|
### 2) JSX component inside JS-first components
|
|
@@ -143,7 +176,7 @@ export function MyReactComponent() {
|
|
|
143
176
|
if (!containerRef.current) return
|
|
144
177
|
|
|
145
178
|
// Mount Vani into a React-managed DOM element (the mounting point)
|
|
146
|
-
vaniHandlesRef.current = renderToDOM(
|
|
179
|
+
vaniHandlesRef.current = renderToDOM(VaniCounter(), containerRef.current)
|
|
147
180
|
|
|
148
181
|
// Cleanup when React unmounts
|
|
149
182
|
return () => {
|
|
@@ -160,6 +193,146 @@ export function MyReactComponent() {
|
|
|
160
193
|
|
|
161
194
|
---
|
|
162
195
|
|
|
196
|
+
## Philosophy and positioning
|
|
197
|
+
|
|
198
|
+
### Core design principles
|
|
199
|
+
|
|
200
|
+
| Principle | Meaning |
|
|
201
|
+
| ----------------- | ----------------------------------------- |
|
|
202
|
+
| Explicit updates | Only `handle.update()` triggers rendering |
|
|
203
|
+
| Locality | Updates affect only the owning subtree |
|
|
204
|
+
| Determinism | Same inputs → same DOM mutations |
|
|
205
|
+
| No hidden work | No background tracking or diffing |
|
|
206
|
+
| Runtime clarity | Debuggable without tooling |
|
|
207
|
+
| Opt-in complexity | Advanced features are explicit |
|
|
208
|
+
|
|
209
|
+
### Goals
|
|
210
|
+
|
|
211
|
+
- Predictable performance at scale
|
|
212
|
+
- Leaf-only updates by default
|
|
213
|
+
- Zero runtime dependencies
|
|
214
|
+
- SSR without hydration heuristics
|
|
215
|
+
- Clear mental model for developers
|
|
216
|
+
- Long-term maintainability over convenience
|
|
217
|
+
- Web-standards-first
|
|
218
|
+
- ESM-first and designed to run in any modern environment
|
|
219
|
+
- Good and intuitive developer experience
|
|
220
|
+
- Reduce magic and complexity, give freedom back to the developers
|
|
221
|
+
|
|
222
|
+
### Ergonomic features
|
|
223
|
+
|
|
224
|
+
- Real HTML attribute names, with a small set of library-specific exceptions (`ref`, `key`,
|
|
225
|
+
`fallback`, `clientOnly`)
|
|
226
|
+
- Optional fine-grained state with `signal()`, `derive()`, `effect()`, plus `text()`/`attr()`
|
|
227
|
+
helpers
|
|
228
|
+
- Async components with fallbacks
|
|
229
|
+
- `className` accepts string, array, and object forms for ergonomic composition
|
|
230
|
+
- ESM-first and designed to run in any modern environment
|
|
231
|
+
|
|
232
|
+
### What Vani is NOT
|
|
233
|
+
|
|
234
|
+
- ❌ Not a Virtual DOM
|
|
235
|
+
- ❌ Not reactive-by-default (signals are opt-in)
|
|
236
|
+
- ❌ Not JSX-mandatory (optional adapter)
|
|
237
|
+
- ❌ Not compiler-driven
|
|
238
|
+
- ❌ Not a template language
|
|
239
|
+
- ❌ Not a framework that guesses intent
|
|
240
|
+
|
|
241
|
+
### Why not Web Components (yet)
|
|
242
|
+
|
|
243
|
+
Vani does not use Web Components today because the developer ergonomics are still rough and SSR
|
|
244
|
+
support is a key goal. We may revisit this if Web Components bring clear benefits without harming
|
|
245
|
+
productivity and cross-browser compatibility.
|
|
246
|
+
|
|
247
|
+
### Comparison with popular frameworks
|
|
248
|
+
|
|
249
|
+
| Feature / Framework | Vani | React | Vue | Svelte | Solid |
|
|
250
|
+
| ---------------------- | ---- | ----- | --- | ------ | ----- |
|
|
251
|
+
| Virtual DOM | ❌ | ✅ | ✅ | ❌ | ❌ |
|
|
252
|
+
| Implicit reactivity | ❌ | ⚠️ | ✅ | ✅ | ✅ |
|
|
253
|
+
| Compiler required | ❌ | ❌ | ❌ | ✅ | ❌ |
|
|
254
|
+
| JSX required | ❌ | ✅ | ❌ | ❌ | ❌ |
|
|
255
|
+
| Explicit updates | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
256
|
+
| Leaf-only updates | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
257
|
+
| Runtime-only | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ |
|
|
258
|
+
| SSR without heuristics | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
259
|
+
| Dependency-free core | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
260
|
+
|
|
261
|
+
⚠️ = partially / indirectly supported / average
|
|
262
|
+
|
|
263
|
+
The strength of Vani is its predictability and simplicity, while other frameworks focus on developer
|
|
264
|
+
productivity and ease of use, handling a lot of complexity behind the scenes automatically.
|
|
265
|
+
|
|
266
|
+
### Vani's sweet spot
|
|
267
|
+
|
|
268
|
+
✅ Perfect for:
|
|
269
|
+
|
|
270
|
+
- Dashboard widgets
|
|
271
|
+
- Micro-frontends
|
|
272
|
+
- Live-coding in the browser
|
|
273
|
+
- Embeddable components in other frameworks
|
|
274
|
+
- Performance-critical UIs where you need exact control
|
|
275
|
+
- Server-rendered sites
|
|
276
|
+
- Learning UI fundamentals (no magic, direct DOM)
|
|
277
|
+
- Lightweight SPAs or small Multi-Page Applications
|
|
278
|
+
|
|
279
|
+
❌ Not ideal for:
|
|
280
|
+
|
|
281
|
+
- Large, complex web applications with many interrelated states
|
|
282
|
+
- Teams that want framework conventions to handle complexity
|
|
283
|
+
- Projects needing a mature ecosystem
|
|
284
|
+
|
|
285
|
+
(at least not yet)
|
|
286
|
+
|
|
287
|
+
### Mental model
|
|
288
|
+
|
|
289
|
+
Think of Vani as:
|
|
290
|
+
|
|
291
|
+
> **“Manually invalidated, DOM-owned UI subtrees.”**
|
|
292
|
+
|
|
293
|
+
You decide:
|
|
294
|
+
|
|
295
|
+
- when something updates
|
|
296
|
+
- how much updates
|
|
297
|
+
- and why it updates
|
|
298
|
+
|
|
299
|
+
Nothing else happens behind your back.
|
|
300
|
+
|
|
301
|
+
### Who Vani is for
|
|
302
|
+
|
|
303
|
+
Vani is a good fit if you value:
|
|
304
|
+
|
|
305
|
+
- full control over rendering
|
|
306
|
+
- predictable performance
|
|
307
|
+
- small runtimes
|
|
308
|
+
- explicit data flow
|
|
309
|
+
- SSR without complexity
|
|
310
|
+
- understanding your tools deeply
|
|
311
|
+
|
|
312
|
+
It is **not** optimized for:
|
|
313
|
+
|
|
314
|
+
- rapid prototyping
|
|
315
|
+
- beginners
|
|
316
|
+
- implicit magic
|
|
317
|
+
- large teams that rely on conventions
|
|
318
|
+
|
|
319
|
+
### Status
|
|
320
|
+
|
|
321
|
+
Vani is experimental and evolving. The core architecture is intentionally small and stable.
|
|
322
|
+
|
|
323
|
+
Expect:
|
|
324
|
+
|
|
325
|
+
- iteration
|
|
326
|
+
- refinement
|
|
327
|
+
- careful additions
|
|
328
|
+
|
|
329
|
+
Not:
|
|
330
|
+
|
|
331
|
+
- rapid feature creep
|
|
332
|
+
- breaking conceptual changes
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
163
336
|
## Core Concepts
|
|
164
337
|
|
|
165
338
|
### 1) Components are functions
|
|
@@ -215,21 +388,34 @@ Updates replace only the DOM between anchors.
|
|
|
215
388
|
|
|
216
389
|
### 4) Lists and item-level updates
|
|
217
390
|
|
|
218
|
-
Lists
|
|
219
|
-
|
|
220
|
-
|
|
391
|
+
Lists scale well in Vani when each item is its own component. Every item owns a tiny subtree and can
|
|
392
|
+
update itself (or be updated via a ref) without touching siblings. Use `key` to preserve identity
|
|
393
|
+
across reorders.
|
|
221
394
|
|
|
222
395
|
Key ideas:
|
|
223
396
|
|
|
224
397
|
- Represent list data by id (Map or array + id).
|
|
225
398
|
- Render each row as a keyed component.
|
|
226
399
|
- Store a `ComponentRef` per id so you can call `ref.current?.update()` for that item only.
|
|
227
|
-
- Call
|
|
400
|
+
- Call `renderKeyedChildren()` for structural list changes (add/remove/reorder).
|
|
401
|
+
- Keys are only respected by `renderKeyedChildren()`. Passing keyed children into `div()` or another
|
|
402
|
+
component does not trigger keyed diffing.
|
|
228
403
|
|
|
229
404
|
Example:
|
|
230
405
|
|
|
231
406
|
```ts
|
|
232
|
-
import {
|
|
407
|
+
import {
|
|
408
|
+
component,
|
|
409
|
+
div,
|
|
410
|
+
ul,
|
|
411
|
+
li,
|
|
412
|
+
input,
|
|
413
|
+
button,
|
|
414
|
+
renderKeyedChildren,
|
|
415
|
+
type ComponentRef,
|
|
416
|
+
type DomRef,
|
|
417
|
+
type Handle,
|
|
418
|
+
} from '@vanijs/vani'
|
|
233
419
|
|
|
234
420
|
type Todo = { id: string; text: string; done: boolean }
|
|
235
421
|
|
|
@@ -267,6 +453,7 @@ const List = component((_, handle: Handle) => {
|
|
|
267
453
|
])
|
|
268
454
|
|
|
269
455
|
const refs = new Map<string, ComponentRef>()
|
|
456
|
+
const listRef: DomRef<HTMLUListElement> = { current: null }
|
|
270
457
|
const getRef = (id: string) => {
|
|
271
458
|
let ref = refs.get(id)
|
|
272
459
|
if (!ref) {
|
|
@@ -293,32 +480,43 @@ const List = component((_, handle: Handle) => {
|
|
|
293
480
|
|
|
294
481
|
const rename = (id: string, text: string) => updateItem(id, { text })
|
|
295
482
|
|
|
483
|
+
const renderRows = () => {
|
|
484
|
+
if (!listRef.current) return
|
|
485
|
+
renderKeyedChildren(
|
|
486
|
+
listRef.current,
|
|
487
|
+
order.map((id) =>
|
|
488
|
+
Row({
|
|
489
|
+
key: id,
|
|
490
|
+
ref: getRef(id),
|
|
491
|
+
id,
|
|
492
|
+
getItem,
|
|
493
|
+
onToggle: toggle,
|
|
494
|
+
onRename: rename,
|
|
495
|
+
}),
|
|
496
|
+
),
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
handle.effect(() => {
|
|
500
|
+
queueMicrotask(renderRows)
|
|
501
|
+
})
|
|
502
|
+
|
|
296
503
|
const add = (text: string) => {
|
|
297
504
|
const id = String(order.length + 1)
|
|
298
505
|
items.set(id, { id, text, done: false })
|
|
299
506
|
order = [...order, id]
|
|
300
|
-
|
|
507
|
+
renderRows()
|
|
301
508
|
}
|
|
302
509
|
|
|
303
510
|
const remove = (id: string) => {
|
|
304
511
|
items.delete(id)
|
|
305
512
|
refs.delete(id)
|
|
306
513
|
order = order.filter((value) => value !== id)
|
|
307
|
-
|
|
514
|
+
renderRows()
|
|
308
515
|
}
|
|
309
516
|
|
|
310
517
|
return () =>
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
Row({
|
|
314
|
-
key: id,
|
|
315
|
-
ref: getRef(id),
|
|
316
|
-
id,
|
|
317
|
-
getItem,
|
|
318
|
-
onToggle: toggle,
|
|
319
|
-
onRename: rename,
|
|
320
|
-
}),
|
|
321
|
-
),
|
|
518
|
+
div(
|
|
519
|
+
ul({ ref: listRef }),
|
|
322
520
|
button({ onclick: () => add('New item') }, 'Add'),
|
|
323
521
|
button(
|
|
324
522
|
{
|
|
@@ -334,7 +532,7 @@ const List = component((_, handle: Handle) => {
|
|
|
334
532
|
```
|
|
335
533
|
|
|
336
534
|
This pattern keeps updates local: changing an item triggers only that row’s subtree update, while
|
|
337
|
-
structural
|
|
535
|
+
structural changes are handled by `renderKeyedChildren()` on the list container.
|
|
338
536
|
|
|
339
537
|
---
|
|
340
538
|
|
|
@@ -471,7 +669,31 @@ component’s subtree; the conditional element is added or removed accordingly.
|
|
|
471
669
|
|
|
472
670
|
---
|
|
473
671
|
|
|
474
|
-
### 7)
|
|
672
|
+
### 7) Signals (optional)
|
|
673
|
+
|
|
674
|
+
Signals are opt-in fine-grained state. They do **not** auto-render components; you either bind them
|
|
675
|
+
directly to DOM helpers like `text()` / `attr()` or explicitly call `handle.update()`.
|
|
676
|
+
|
|
677
|
+
Example:
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
import { component, button, div, signal, text } from '@vanijs/vani'
|
|
681
|
+
|
|
682
|
+
const Counter = component(() => {
|
|
683
|
+
const [count, setCount] = signal(0)
|
|
684
|
+
return () =>
|
|
685
|
+
div(
|
|
686
|
+
text(() => `Count: ${count()}`),
|
|
687
|
+
button({ onclick: () => setCount((value) => value + 1) }, 'Inc'),
|
|
688
|
+
)
|
|
689
|
+
})
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
Use `derive()` for computed getters and `effect()` for side effects tied to signals.
|
|
693
|
+
|
|
694
|
+
---
|
|
695
|
+
|
|
696
|
+
### 8) Scheduling across independent regions
|
|
475
697
|
|
|
476
698
|
In large apps, keep each UI region as its own component root, and schedule updates explicitly. Use
|
|
477
699
|
microtasks for immediate batching and `startTransition()` for non‑urgent work. This lets you control
|
|
@@ -522,6 +744,82 @@ This design keeps scheduling predictable: each region updates at most once per f
|
|
|
522
744
|
decide which updates are urgent vs. deferred. If a region needs data from another, call its public
|
|
523
745
|
API first, then schedule both regions explicitly in the same flush.
|
|
524
746
|
|
|
747
|
+
### Explicit multi-region scheduling strategy
|
|
748
|
+
|
|
749
|
+
When multiple independent UI regions update in the same tick, prefer a central scheduler that:
|
|
750
|
+
|
|
751
|
+
- deduplicates updates per region (one update per flush)
|
|
752
|
+
- batches synchronous state changes into a microtask
|
|
753
|
+
- separates urgent vs. non‑urgent work (`queueMicrotask` vs. `startTransition`)
|
|
754
|
+
- avoids cascades by flushing a single pass and re‑queueing if new work appears
|
|
755
|
+
|
|
756
|
+
Example:
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
import { startTransition, type Handle } from '@vanijs/vani'
|
|
760
|
+
|
|
761
|
+
type RegionId = 'header' | 'content' | 'sidebar' | 'status'
|
|
762
|
+
type Priority = 'urgent' | 'transition'
|
|
763
|
+
|
|
764
|
+
const handles = new Map<RegionId, Handle>()
|
|
765
|
+
const pendingUrgent = new Set<RegionId>()
|
|
766
|
+
const pendingTransition = new Set<RegionId>()
|
|
767
|
+
let microtaskScheduled = false
|
|
768
|
+
let transitionScheduled = false
|
|
769
|
+
|
|
770
|
+
export const registerRegion = (id: RegionId, handle: Handle) => {
|
|
771
|
+
handles.set(id, handle)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export const scheduleRegion = (id: RegionId, priority: Priority = 'urgent') => {
|
|
775
|
+
if (priority === 'transition') {
|
|
776
|
+
pendingTransition.add(id)
|
|
777
|
+
if (!transitionScheduled) {
|
|
778
|
+
transitionScheduled = true
|
|
779
|
+
startTransition(flushTransition)
|
|
780
|
+
}
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
pendingUrgent.add(id)
|
|
785
|
+
if (!microtaskScheduled) {
|
|
786
|
+
microtaskScheduled = true
|
|
787
|
+
queueMicrotask(flushUrgent)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const flushUrgent = () => {
|
|
792
|
+
microtaskScheduled = false
|
|
793
|
+
for (const id of pendingUrgent) {
|
|
794
|
+
handles.get(id)?.update()
|
|
795
|
+
}
|
|
796
|
+
pendingUrgent.clear()
|
|
797
|
+
|
|
798
|
+
// If urgent updates caused more urgent work, queue another microtask.
|
|
799
|
+
if (pendingUrgent.size > 0 && !microtaskScheduled) {
|
|
800
|
+
microtaskScheduled = true
|
|
801
|
+
queueMicrotask(flushUrgent)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const flushTransition = () => {
|
|
806
|
+
transitionScheduled = false
|
|
807
|
+
for (const id of pendingTransition) {
|
|
808
|
+
handles.get(id)?.update()
|
|
809
|
+
}
|
|
810
|
+
pendingTransition.clear()
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
Guidelines:
|
|
815
|
+
|
|
816
|
+
- **Priority conflicts**: if a region is queued in both sets, let urgent win and clear it from
|
|
817
|
+
transition during flush.
|
|
818
|
+
- **Cascading updates**: if an update triggers more updates, re‑queue explicitly rather than looping
|
|
819
|
+
synchronously.
|
|
820
|
+
- **Predictability**: keep region IDs stable and avoid cross‑region reads during render; use
|
|
821
|
+
coordinators to update shared data first, then schedule regions in a known order.
|
|
822
|
+
|
|
525
823
|
---
|
|
526
824
|
|
|
527
825
|
## Advanced patterns
|
|
@@ -627,42 +925,78 @@ export const emit = (event: string, payload?: unknown) => {
|
|
|
627
925
|
|
|
628
926
|
## Large-scale app architecture
|
|
629
927
|
|
|
630
|
-
Vani scales best when
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
928
|
+
Vani scales best when update paths stay explicit and module boundaries stay tight. Treat updates as
|
|
929
|
+
messages: state lives in a feature module, views read snapshots, and invalidation is triggered by
|
|
930
|
+
that module's commands. This prevents hidden dependencies and makes cross-feature coordination an
|
|
931
|
+
explicit, testable surface.
|
|
932
|
+
|
|
933
|
+
### Architecting large-scale, coordinated modules
|
|
934
|
+
|
|
935
|
+
Use explicit feature modules with clear APIs, bind views to module subscriptions, and coordinate
|
|
936
|
+
cross-module workflows through a small coordinator or typed event channel. Keep invalidation scoped
|
|
937
|
+
to feature roots or keyed row components, and batch updates per handle. Avoid implicit dependencies
|
|
938
|
+
by never mutating another module's state directly; instead call exported commands or emit events. At
|
|
939
|
+
scale, manual invalidation challenges include fan-out, over- or under-invalidating, update
|
|
940
|
+
ordering/races, stale reads during transitions, lifecycle leaks, and lack of observability. Mitigate
|
|
941
|
+
with explicit contracts, centralized command surfaces, predictable ordering, cleanup via
|
|
942
|
+
`handle.effect()`, and lightweight logging around invalidation helpers.
|
|
943
|
+
|
|
944
|
+
Architecture sketch:
|
|
945
|
+
|
|
946
|
+
```
|
|
947
|
+
[UI/View A] --subscribe--> [Feature A]
|
|
948
|
+
| |
|
|
949
|
+
| update() | commands
|
|
950
|
+
v v
|
|
951
|
+
[Handle A] [Coordinator] ----> [Feature B]
|
|
952
|
+
^ | |
|
|
953
|
+
| update() | events | notify
|
|
954
|
+
[UI/View B] --subscribe--> [Feature B] <---------+
|
|
955
|
+
```
|
|
635
956
|
|
|
636
957
|
### Suggested architecture
|
|
637
958
|
|
|
638
959
|
1. Feature modules (state + commands)
|
|
639
960
|
|
|
640
|
-
Each module exposes:
|
|
961
|
+
Each module owns its state and exposes a small API:
|
|
641
962
|
|
|
642
|
-
-
|
|
643
|
-
-
|
|
644
|
-
-
|
|
645
|
-
-
|
|
963
|
+
- snapshot getters (`getState`, `getUsers`, `getFilters`)
|
|
964
|
+
- commands that mutate state (`setUsers`, `applyFilter`)
|
|
965
|
+
- `subscribe(listener)` to notify views of invalidation
|
|
966
|
+
- optional selectors for derived data
|
|
646
967
|
|
|
647
968
|
2. View adapters (bind handles)
|
|
648
969
|
|
|
649
970
|
Views subscribe once via `handle.effect()` and call `handle.update()` when their module notifies.
|
|
650
971
|
This keeps invalidation scoped to the subtree that owns the handle.
|
|
651
972
|
|
|
652
|
-
3. Coordinator
|
|
973
|
+
3. Coordinator or message hub
|
|
653
974
|
|
|
654
|
-
For
|
|
975
|
+
For workflows that span multiple modules, add a thin coordinator that:
|
|
655
976
|
|
|
656
|
-
- orchestrates sequences (
|
|
977
|
+
- orchestrates sequences (save → refresh → notify)
|
|
657
978
|
- calls public APIs of each module
|
|
658
|
-
- never
|
|
979
|
+
- never reaches into private state
|
|
980
|
+
- batches invalidations when multiple modules change together
|
|
659
981
|
|
|
660
982
|
4. Stable, explicit contracts
|
|
661
983
|
|
|
662
|
-
Use interfaces,
|
|
984
|
+
Use interfaces, small message payloads, or callbacks to avoid implicit coupling. If one feature
|
|
663
985
|
needs another to update, it calls that module's exported command (or `invalidate()` helper) rather
|
|
664
986
|
than mutating shared data.
|
|
665
987
|
|
|
988
|
+
### Cross-module coordination patterns
|
|
989
|
+
|
|
990
|
+
Pick one, keep it explicit, and avoid hidden dependencies:
|
|
991
|
+
|
|
992
|
+
- **Coordinator**: a domain-level workflow function that calls module commands in order.
|
|
993
|
+
- **Event channel**: a small bus where modules emit typed events and subscribers decide how to
|
|
994
|
+
update; views still call `handle.update()` explicitly.
|
|
995
|
+
- **Shared readonly data**: for truly global data, use a shared store with strict write APIs and
|
|
996
|
+
localized subscriptions.
|
|
997
|
+
|
|
998
|
+
The goal is to keep "who invalidates whom" visible at the call site.
|
|
999
|
+
|
|
666
1000
|
### Example: Feature module with explicit invalidation
|
|
667
1001
|
|
|
668
1002
|
```ts
|
|
@@ -729,6 +1063,33 @@ export const createCoordinator = (users: UsersFeatureApi): Coordinator => {
|
|
|
729
1063
|
}
|
|
730
1064
|
```
|
|
731
1065
|
|
|
1066
|
+
### Example: Event channel for cross-feature updates
|
|
1067
|
+
|
|
1068
|
+
```ts
|
|
1069
|
+
type EventMap = {
|
|
1070
|
+
userSaved: { id: string }
|
|
1071
|
+
searchChanged: { query: string }
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
type Listener<K extends keyof EventMap> = (payload: EventMap[K]) => void
|
|
1075
|
+
const listeners = new Map<keyof EventMap, Set<Listener<keyof EventMap>>>()
|
|
1076
|
+
|
|
1077
|
+
export const on = <K extends keyof EventMap>(event: K, listener: Listener<K>) => {
|
|
1078
|
+
const set = listeners.get(event) ?? new Set()
|
|
1079
|
+
set.add(listener as Listener<keyof EventMap>)
|
|
1080
|
+
listeners.set(event, set)
|
|
1081
|
+
return () => set.delete(listener as Listener<keyof EventMap>)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
export const emit = <K extends keyof EventMap>(event: K, payload: EventMap[K]) => {
|
|
1085
|
+
const set = listeners.get(event)
|
|
1086
|
+
if (!set) return
|
|
1087
|
+
for (const listener of set) {
|
|
1088
|
+
listener(payload)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
```
|
|
1092
|
+
|
|
732
1093
|
### Example: Scoped invalidation by key
|
|
733
1094
|
|
|
734
1095
|
When a large list exists, invalidate only the affected rows.
|
|
@@ -755,44 +1116,55 @@ export const UserRow = component<{ id: string; name: string }>((props, handle) =
|
|
|
755
1116
|
|
|
756
1117
|
### Explicit batching (optional)
|
|
757
1118
|
|
|
758
|
-
If you dispatch many invalidations in a single tick,
|
|
1119
|
+
If you dispatch many invalidations in a single tick, you can wrap them in `batch()` or queue them
|
|
1120
|
+
and update once per handle.
|
|
1121
|
+
|
|
1122
|
+
Useful cases:
|
|
1123
|
+
|
|
1124
|
+
- Multiple store mutations in one click (only one update per handle).
|
|
1125
|
+
- Toggling many rows or cards at once.
|
|
1126
|
+
- Applying filters that update multiple feature roots.
|
|
759
1127
|
|
|
760
1128
|
```ts
|
|
761
|
-
import type
|
|
1129
|
+
import { batch, type Handle } from '@vanijs/vani'
|
|
762
1130
|
|
|
763
1131
|
const pending = new Set<Handle>()
|
|
764
1132
|
let scheduled = false
|
|
765
1133
|
|
|
766
1134
|
export const queueUpdate = (handle: Handle) => {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1135
|
+
batch(() => {
|
|
1136
|
+
pending.add(handle)
|
|
1137
|
+
if (scheduled) return
|
|
1138
|
+
scheduled = true
|
|
1139
|
+
queueMicrotask(() => {
|
|
1140
|
+
scheduled = false
|
|
1141
|
+
for (const item of pending) item.update()
|
|
1142
|
+
pending.clear()
|
|
1143
|
+
})
|
|
774
1144
|
})
|
|
775
1145
|
}
|
|
776
1146
|
```
|
|
777
1147
|
|
|
778
1148
|
### Challenges with manual invalidation at scale
|
|
779
1149
|
|
|
780
|
-
- Update fan-out:
|
|
781
|
-
|
|
782
|
-
- Over-invalidating: calling `handle.update()`
|
|
783
|
-
small, keyed targets (row components, feature roots).
|
|
784
|
-
- Under-invalidating: missing
|
|
785
|
-
|
|
786
|
-
- Ordering and race conditions: when
|
|
787
|
-
invalidate in a
|
|
788
|
-
-
|
|
789
|
-
|
|
790
|
-
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1150
|
+
- Update fan-out: a single command may need to notify many modules. Use a coordinator or explicit
|
|
1151
|
+
event channel so the fan-out is visible and testable.
|
|
1152
|
+
- Over-invalidating: calling `handle.update()` on large roots can cause avoidable work. Prefer
|
|
1153
|
+
small, keyed targets (row components, feature roots) and batch per handle.
|
|
1154
|
+
- Under-invalidating: missing a manual update leaves views stale. Make "update after mutation" part
|
|
1155
|
+
of the module contract and centralize mutations behind commands.
|
|
1156
|
+
- Ordering and race conditions: when modules depend on shared data, update data first, then
|
|
1157
|
+
invalidate in a stable order; avoid interleaving async refreshes without a coordinator.
|
|
1158
|
+
- Stale reads during transitions: if you defer with `startTransition()`, ensure that the render
|
|
1159
|
+
reads the latest snapshot or that the transition captures the intended version.
|
|
1160
|
+
- Lifecycle leaks: if a handle isn't unsubscribed, updates keep firing. Always return cleanup from
|
|
1161
|
+
`subscribe()` and bind it through `handle.effect()`.
|
|
1162
|
+
- Observability gaps: without implicit reactivity, you need traceability. Wrap invalidation helpers
|
|
1163
|
+
to log or count updates per module and catch runaway loops early.
|
|
1164
|
+
|
|
1165
|
+
Vani trades automatic coordination for transparency. In large apps, invest in clear module
|
|
1166
|
+
boundaries, explicit cross-module APIs, and small invalidation targets to keep manual invalidation
|
|
1167
|
+
manageable.
|
|
796
1168
|
|
|
797
1169
|
---
|
|
798
1170
|
|
|
@@ -818,7 +1190,7 @@ Components can return other component instances directly:
|
|
|
818
1190
|
|
|
819
1191
|
```ts
|
|
820
1192
|
import { component } from '@vanijs/vani'
|
|
821
|
-
import * as h from 'vani
|
|
1193
|
+
import * as h from '@vanijs/vani'
|
|
822
1194
|
|
|
823
1195
|
const Hero = component(() => {
|
|
824
1196
|
return () => h.h1('Hello')
|
|
@@ -831,39 +1203,40 @@ const Page = component(() => {
|
|
|
831
1203
|
|
|
832
1204
|
### `renderToDOM(components, root)`
|
|
833
1205
|
|
|
834
|
-
Mounts components
|
|
1206
|
+
Mounts components and schedules the first render on the next microtask. Accepts a single component
|
|
1207
|
+
or an array of components.
|
|
835
1208
|
|
|
836
1209
|
```ts
|
|
837
1210
|
import { renderToDOM, component, div } from '@vanijs/vani'
|
|
838
1211
|
|
|
839
1212
|
const App = component(() => () => div('App'))
|
|
840
|
-
renderToDOM(
|
|
1213
|
+
renderToDOM(App(), document.getElementById('app')!)
|
|
841
1214
|
```
|
|
842
1215
|
|
|
843
1216
|
### `hydrateToDOM(components, root)`
|
|
844
1217
|
|
|
845
|
-
Binds handles to existing DOM (SSR/SSG) without rendering.
|
|
846
|
-
activate.
|
|
1218
|
+
Binds handles to existing DOM (SSR/SSG) without rendering. Accepts a single component or an array of
|
|
1219
|
+
components. You must call `handle.update()` to activate.
|
|
847
1220
|
|
|
848
1221
|
```ts
|
|
849
1222
|
import { hydrateToDOM } from '@vanijs/vani'
|
|
850
1223
|
import { App } from './app'
|
|
851
1224
|
|
|
852
1225
|
const root = document.getElementById('app')!
|
|
853
|
-
const handles = hydrateToDOM(
|
|
1226
|
+
const handles = hydrateToDOM(App(), root)
|
|
854
1227
|
handles.forEach((handle) => handle.update())
|
|
855
1228
|
```
|
|
856
1229
|
|
|
857
1230
|
### `renderToString(components)`
|
|
858
1231
|
|
|
859
|
-
Server‑side render to HTML with anchors.
|
|
1232
|
+
Server‑side render to HTML with anchors. Accepts a single component or an array of components.
|
|
1233
|
+
Import from `@vanijs/vani`.
|
|
860
1234
|
|
|
861
1235
|
```ts
|
|
862
|
-
import { component } from '@vanijs/vani'
|
|
863
|
-
import { renderToString } from 'vani/ssr'
|
|
1236
|
+
import { component, renderToString } from '@vanijs/vani'
|
|
864
1237
|
|
|
865
1238
|
const App = component(() => () => 'Hello SSR')
|
|
866
|
-
const html = await renderToString(
|
|
1239
|
+
const html = await renderToString(App())
|
|
867
1240
|
```
|
|
868
1241
|
|
|
869
1242
|
### `mount(component, props)`
|
|
@@ -901,21 +1274,38 @@ div(span('Label'), input({ type: 'text' }), button({ onclick: () => {} }, 'Submi
|
|
|
901
1274
|
|
|
902
1275
|
### SVG icons (Lucide)
|
|
903
1276
|
|
|
904
|
-
|
|
905
|
-
|
|
1277
|
+
Use the Vite SVG plugin at `src/ecosystem/vite-plugin-vani-svg.ts` and import SVGs with `?vani`.
|
|
1278
|
+
This keeps the bundle small by only including the icons you actually import.
|
|
1279
|
+
|
|
1280
|
+
In your `vite.config.ts`:
|
|
1281
|
+
|
|
1282
|
+
```ts
|
|
1283
|
+
import vitePluginVaniSvg from './src/ecosystem/vite-plugin-vani-svg'
|
|
1284
|
+
|
|
1285
|
+
export default defineConfig({
|
|
1286
|
+
plugins: [vitePluginVaniSvg()],
|
|
1287
|
+
})
|
|
1288
|
+
```
|
|
906
1289
|
|
|
907
1290
|
```ts
|
|
1291
|
+
import GithubIcon from 'lucide-static/icons/github.svg?vani'
|
|
908
1292
|
import { component } from '@vanijs/vani'
|
|
909
|
-
import { renderSvgString } from '@vanijs/vani/svg'
|
|
910
|
-
import { Github } from 'lucide-static'
|
|
911
1293
|
|
|
912
1294
|
const GithubLink = component(() => {
|
|
913
|
-
return () =>
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1295
|
+
return () => GithubIcon({ size: 16, className: 'h-4 w-4', 'aria-hidden': true })
|
|
1296
|
+
})
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
### SVGs as components
|
|
1300
|
+
|
|
1301
|
+
Any SVG can be turned into a Vani component with the same `?vani` suffix.
|
|
1302
|
+
|
|
1303
|
+
```ts
|
|
1304
|
+
import LogoIcon from './logo.svg?vani'
|
|
1305
|
+
import { component } from '@vanijs/vani'
|
|
1306
|
+
|
|
1307
|
+
const HeaderLogo = component(() => {
|
|
1308
|
+
return () => LogoIcon({ className: 'h-8 w-8', 'aria-hidden': true })
|
|
919
1309
|
})
|
|
920
1310
|
```
|
|
921
1311
|
|
|
@@ -968,6 +1358,47 @@ const Timer = component((_, handle) => {
|
|
|
968
1358
|
})
|
|
969
1359
|
```
|
|
970
1360
|
|
|
1361
|
+
### Signals and DOM bindings (optional)
|
|
1362
|
+
|
|
1363
|
+
Signals are opt-in fine-grained state. They update only the DOM nodes bound to them.
|
|
1364
|
+
|
|
1365
|
+
```ts
|
|
1366
|
+
import { component, button, div, signal, text } from '@vanijs/vani'
|
|
1367
|
+
|
|
1368
|
+
const Counter = component(() => {
|
|
1369
|
+
const [count, setCount] = signal(0)
|
|
1370
|
+
return () =>
|
|
1371
|
+
div(
|
|
1372
|
+
text(() => `Count: ${count()}`),
|
|
1373
|
+
button({ onclick: () => setCount((value) => value + 1) }, 'Inc'),
|
|
1374
|
+
)
|
|
1375
|
+
})
|
|
1376
|
+
```
|
|
1377
|
+
|
|
1378
|
+
- `signal(initial)` returns `[get, set]`.
|
|
1379
|
+
- `derive(fn)` returns a computed getter.
|
|
1380
|
+
- `effect(fn)` re-runs when signals used inside `fn` change.
|
|
1381
|
+
- `text(getter)` binds a text node to a signal.
|
|
1382
|
+
- `attr(el, name, getter)` binds an attribute/class to a signal.
|
|
1383
|
+
|
|
1384
|
+
### Partial attribute updates
|
|
1385
|
+
|
|
1386
|
+
When you only need to update attributes (like `className`), you can request an attribute-only
|
|
1387
|
+
refresh:
|
|
1388
|
+
|
|
1389
|
+
```ts
|
|
1390
|
+
ref.current?.update({ onlyAttributes: true })
|
|
1391
|
+
```
|
|
1392
|
+
|
|
1393
|
+
This preserves existing event listeners and children and only patches the root element’s attributes.
|
|
1394
|
+
It applies when the component returns a single root element.
|
|
1395
|
+
|
|
1396
|
+
Useful cases:
|
|
1397
|
+
|
|
1398
|
+
- Toggle a selected row class without touching children.
|
|
1399
|
+
- Flip aria/disabled flags on a button.
|
|
1400
|
+
- Update theme/state classes on a card while leaving its subtree intact.
|
|
1401
|
+
|
|
971
1402
|
### Transitions
|
|
972
1403
|
|
|
973
1404
|
`startTransition` marks a group of updates as non-urgent, so they are deferred and batched
|