@vanijs/vani 0.1.0 → 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/DOCS.md +782 -7
- package/README.md +51 -3
- package/dist/lib/index.d.mts +31 -158
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/jsx-dev-runtime.d.mts +2 -0
- package/dist/lib/jsx-dev-runtime.mjs +1 -0
- package/dist/lib/jsx-runtime.d.mts +27 -0
- package/dist/lib/jsx-runtime.mjs +1 -0
- package/dist/lib/runtime-BIk4OH1t.mjs +1 -0
- package/dist/lib/runtime-Dp-nlil7.d.mts +166 -0
- package/llms.txt +1 -6
- package/package.json +61 -19
package/DOCS.md
CHANGED
|
@@ -12,7 +12,7 @@ DOM subtree delimited by anchors and only update when you explicitly ask them to
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
pnpm add vani
|
|
15
|
+
pnpm add @vanijs/vani
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
---
|
|
@@ -47,6 +47,119 @@ renderToDOM([Counter()], appRoot)
|
|
|
47
47
|
|
|
48
48
|
---
|
|
49
49
|
|
|
50
|
+
## JSX mode examples
|
|
51
|
+
|
|
52
|
+
Vani is JS-first and transpiler-free, with an optional JSX adapter. JSX mode requires
|
|
53
|
+
`jsxImportSource` to be set to `@vanijs/vani` and a `.tsx` file.
|
|
54
|
+
|
|
55
|
+
### 1) JSX counter button
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
import { component, renderToDOM, type Handle } from '@vanijs/vani'
|
|
59
|
+
|
|
60
|
+
const Counter = component((_, handle: Handle) => {
|
|
61
|
+
let count = 0
|
|
62
|
+
return () => (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onclick={() => {
|
|
66
|
+
count += 1
|
|
67
|
+
handle.update()
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
Count: {count}
|
|
71
|
+
</button>
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
renderToDOM([Counter()], document.getElementById('app')!)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 2) JSX component inside JS-first components
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { component } from '@vanijs/vani'
|
|
82
|
+
import * as h from '@vanijs/vani/html'
|
|
83
|
+
|
|
84
|
+
const Badge = component<{ label: string }>((props) => {
|
|
85
|
+
return () => <span>{props.label}</span>
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const Panel = component(() => {
|
|
89
|
+
return () =>
|
|
90
|
+
h.div(
|
|
91
|
+
'Mixed render:',
|
|
92
|
+
Badge({ label: 'JSX component' }),
|
|
93
|
+
h.span('inside a JS-first component.'),
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Incremental adoption (mounting points)
|
|
101
|
+
|
|
102
|
+
Vani is intentionally small and lightweight, so you don't need to replace your full stack. You can
|
|
103
|
+
adopt it gradually by mounting Vani components inside existing apps (React, Vue, server-rendered
|
|
104
|
+
pages, etc.) using plain DOM elements as mounting points.
|
|
105
|
+
|
|
106
|
+
Benefits:
|
|
107
|
+
|
|
108
|
+
- Gradual migration: move one widget or screen at a time without a rewrite.
|
|
109
|
+
- Minimal surface area: no global runtime or framework lock-in.
|
|
110
|
+
- Clear ownership: a Vani component owns only its subtree between anchors.
|
|
111
|
+
- Easy rollback: remove a mount point and the rest of the app keeps working.
|
|
112
|
+
|
|
113
|
+
### Example: mount a Vani widget inside React
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { useEffect, useRef } from 'react'
|
|
117
|
+
import { component, div, button, renderToDOM, type Handle } from '@vanijs/vani'
|
|
118
|
+
|
|
119
|
+
// Vani component (local state + explicit updates)
|
|
120
|
+
const VaniCounter = component((_, handle) => {
|
|
121
|
+
let count = 0
|
|
122
|
+
return () =>
|
|
123
|
+
div(
|
|
124
|
+
`Count: ${count}`,
|
|
125
|
+
button(
|
|
126
|
+
{
|
|
127
|
+
onclick: () => {
|
|
128
|
+
count += 1
|
|
129
|
+
handle.update()
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
'Increment',
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// React component that hosts the Vani widget
|
|
138
|
+
export function MyReactComponent() {
|
|
139
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
140
|
+
const vaniHandlesRef = useRef<Handle[] | null>(null)
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!containerRef.current) return
|
|
144
|
+
|
|
145
|
+
// Mount Vani into a React-managed DOM element (the mounting point)
|
|
146
|
+
vaniHandlesRef.current = renderToDOM([VaniCounter()], containerRef.current)
|
|
147
|
+
|
|
148
|
+
// Cleanup when React unmounts
|
|
149
|
+
return () => {
|
|
150
|
+
for (const handle of vaniHandlesRef.current ?? []) {
|
|
151
|
+
handle.dispose()
|
|
152
|
+
}
|
|
153
|
+
vaniHandlesRef.current = null
|
|
154
|
+
}
|
|
155
|
+
}, [])
|
|
156
|
+
|
|
157
|
+
return <div ref={containerRef} />
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
50
163
|
## Core Concepts
|
|
51
164
|
|
|
52
165
|
### 1) Components are functions
|
|
@@ -63,7 +176,9 @@ const Hello = component(() => {
|
|
|
63
176
|
|
|
64
177
|
### 2) Explicit updates
|
|
65
178
|
|
|
66
|
-
|
|
179
|
+
Re-renders are always explicit: call `handle.update()` to refresh a component’s subtree. The only
|
|
180
|
+
automatic update is the initial mount (or `clientOnly` during hydration), which schedules the first
|
|
181
|
+
render for you.
|
|
67
182
|
|
|
68
183
|
```ts
|
|
69
184
|
import { component, div, button, type Handle } from '@vanijs/vani'
|
|
@@ -98,6 +213,587 @@ Each component owns a DOM range delimited by anchors:
|
|
|
98
213
|
|
|
99
214
|
Updates replace only the DOM between anchors.
|
|
100
215
|
|
|
216
|
+
### 4) Lists and item-level updates
|
|
217
|
+
|
|
218
|
+
Lists are efficient in Vani when each item is its own component. Every item owns a tiny subtree and
|
|
219
|
+
can update itself (or be updated via a ref) without touching siblings. Use `key` to preserve
|
|
220
|
+
identity across reorders.
|
|
221
|
+
|
|
222
|
+
Key ideas:
|
|
223
|
+
|
|
224
|
+
- Represent list data by id (Map or array + id).
|
|
225
|
+
- Render each row as a keyed component.
|
|
226
|
+
- Store a `ComponentRef` per id so you can call `ref.current?.update()` for that item only.
|
|
227
|
+
- Call the list handle only when the list structure changes (add/remove/reorder).
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { component, ul, li, input, button, type Handle, type ComponentRef } from '@vanijs/vani'
|
|
233
|
+
|
|
234
|
+
type Todo = { id: string; text: string; done: boolean }
|
|
235
|
+
|
|
236
|
+
const Row = component<{
|
|
237
|
+
id: string
|
|
238
|
+
getItem: (id: string) => Todo | undefined
|
|
239
|
+
onToggle: (id: string) => void
|
|
240
|
+
onRename: (id: string, text: string) => void
|
|
241
|
+
}>((props) => {
|
|
242
|
+
return () => {
|
|
243
|
+
const item = props.getItem(props.id)
|
|
244
|
+
if (!item) return null
|
|
245
|
+
return li(
|
|
246
|
+
input({
|
|
247
|
+
type: 'checkbox',
|
|
248
|
+
checked: item.done,
|
|
249
|
+
onchange: () => props.onToggle(item.id),
|
|
250
|
+
}),
|
|
251
|
+
input({
|
|
252
|
+
value: item.text,
|
|
253
|
+
oninput: (event) => {
|
|
254
|
+
const value = (event.currentTarget as HTMLInputElement).value
|
|
255
|
+
props.onRename(item.id, value)
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const List = component((_, handle: Handle) => {
|
|
263
|
+
let order = ['a', 'b']
|
|
264
|
+
const items = new Map<string, Todo>([
|
|
265
|
+
['a', { id: 'a', text: 'Ship Vani', done: false }],
|
|
266
|
+
['b', { id: 'b', text: 'Write docs', done: true }],
|
|
267
|
+
])
|
|
268
|
+
|
|
269
|
+
const refs = new Map<string, ComponentRef>()
|
|
270
|
+
const getRef = (id: string) => {
|
|
271
|
+
let ref = refs.get(id)
|
|
272
|
+
if (!ref) {
|
|
273
|
+
ref = { current: null }
|
|
274
|
+
refs.set(id, ref)
|
|
275
|
+
}
|
|
276
|
+
return ref
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const getItem = (id: string) => items.get(id)
|
|
280
|
+
|
|
281
|
+
const updateItem = (id: string, next: Partial<Todo>) => {
|
|
282
|
+
const current = items.get(id)
|
|
283
|
+
if (!current) return
|
|
284
|
+
items.set(id, { ...current, ...next })
|
|
285
|
+
refs.get(id)?.current?.update()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const toggle = (id: string) => {
|
|
289
|
+
const current = items.get(id)
|
|
290
|
+
if (!current) return
|
|
291
|
+
updateItem(id, { done: !current.done })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rename = (id: string, text: string) => updateItem(id, { text })
|
|
295
|
+
|
|
296
|
+
const add = (text: string) => {
|
|
297
|
+
const id = String(order.length + 1)
|
|
298
|
+
items.set(id, { id, text, done: false })
|
|
299
|
+
order = [...order, id]
|
|
300
|
+
handle.update()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const remove = (id: string) => {
|
|
304
|
+
items.delete(id)
|
|
305
|
+
refs.delete(id)
|
|
306
|
+
order = order.filter((value) => value !== id)
|
|
307
|
+
handle.update()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return () =>
|
|
311
|
+
ul(
|
|
312
|
+
order.map((id) =>
|
|
313
|
+
Row({
|
|
314
|
+
key: id,
|
|
315
|
+
ref: getRef(id),
|
|
316
|
+
id,
|
|
317
|
+
getItem,
|
|
318
|
+
onToggle: toggle,
|
|
319
|
+
onRename: rename,
|
|
320
|
+
}),
|
|
321
|
+
),
|
|
322
|
+
button({ onclick: () => add('New item') }, 'Add'),
|
|
323
|
+
button(
|
|
324
|
+
{
|
|
325
|
+
onclick: () => {
|
|
326
|
+
const first = order[0]
|
|
327
|
+
if (first) remove(first)
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
'Remove first',
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
This pattern keeps updates local: changing an item triggers only that row’s subtree update, while
|
|
337
|
+
structural list changes re-render the list container and reuse keyed rows.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
### 5) Forms with explicit submit
|
|
342
|
+
|
|
343
|
+
For forms, you can keep input values in local variables and update the DOM only on submit. This
|
|
344
|
+
matches Vani’s model: read input changes without re-rendering, then call `handle.update()` when the
|
|
345
|
+
user explicitly submits.
|
|
346
|
+
|
|
347
|
+
Example:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
import { component, form, label, input, button, div, type Handle } from '@vanijs/vani'
|
|
351
|
+
|
|
352
|
+
const ContactForm = component((_, handle: Handle) => {
|
|
353
|
+
let name = ''
|
|
354
|
+
let email = ''
|
|
355
|
+
let submitted = false
|
|
356
|
+
|
|
357
|
+
const onSubmit = (event: SubmitEvent) => {
|
|
358
|
+
event.preventDefault()
|
|
359
|
+
submitted = true
|
|
360
|
+
handle.update()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return () =>
|
|
364
|
+
form(
|
|
365
|
+
{ onsubmit: onSubmit },
|
|
366
|
+
label('Name'),
|
|
367
|
+
input({
|
|
368
|
+
name: 'name',
|
|
369
|
+
value: name,
|
|
370
|
+
oninput: (event) => {
|
|
371
|
+
name = (event.currentTarget as HTMLInputElement).value
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
label('Email'),
|
|
375
|
+
input({
|
|
376
|
+
name: 'email',
|
|
377
|
+
type: 'email',
|
|
378
|
+
value: email,
|
|
379
|
+
oninput: (event) => {
|
|
380
|
+
email = (event.currentTarget as HTMLInputElement).value
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
button({ type: 'submit' }, 'Send'),
|
|
384
|
+
submitted ? div(`Submitted: ${name} <${email}>`) : null,
|
|
385
|
+
)
|
|
386
|
+
})
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The DOM only updates on submit. Input changes mutate local variables but do not trigger a render
|
|
390
|
+
until the user confirms.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
### 5.1) Inputs and focus
|
|
395
|
+
|
|
396
|
+
Vani replaces a component’s subtree on update. If you re-render on every keystroke, the input node
|
|
397
|
+
is recreated and the browser will drop focus/selection. Prefer uncontrolled inputs and update on
|
|
398
|
+
submit/blur, or split the input into its own component so only a sibling preview re-renders.
|
|
399
|
+
|
|
400
|
+
If you need a controlled input, preserve focus explicitly:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
import { component, div, input, type DomRef, type Handle } from '@vanijs/vani'
|
|
404
|
+
|
|
405
|
+
const ControlledInput = component((_, handle: Handle) => {
|
|
406
|
+
const ref: DomRef<HTMLInputElement> = { current: null }
|
|
407
|
+
let value = ''
|
|
408
|
+
|
|
409
|
+
const updateWithFocus = () => {
|
|
410
|
+
const prev = ref.current
|
|
411
|
+
const start = prev?.selectionStart ?? null
|
|
412
|
+
const end = prev?.selectionEnd ?? null
|
|
413
|
+
|
|
414
|
+
handle.updateSync()
|
|
415
|
+
|
|
416
|
+
const next = ref.current
|
|
417
|
+
if (next) {
|
|
418
|
+
next.focus()
|
|
419
|
+
if (start != null && end != null) {
|
|
420
|
+
next.setSelectionRange(start, end)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return () =>
|
|
426
|
+
div(
|
|
427
|
+
input({
|
|
428
|
+
ref,
|
|
429
|
+
value,
|
|
430
|
+
oninput: (event) => {
|
|
431
|
+
value = (event.currentTarget as HTMLInputElement).value
|
|
432
|
+
updateWithFocus()
|
|
433
|
+
},
|
|
434
|
+
}),
|
|
435
|
+
div(`Value: ${value}`),
|
|
436
|
+
)
|
|
437
|
+
})
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
### 6) Conditional rendering
|
|
443
|
+
|
|
444
|
+
Conditional rendering is just normal control flow inside the render function. You compute a boolean
|
|
445
|
+
from your local state and return either the element or `null`. Updates are still explicit: call
|
|
446
|
+
`handle.update()` when you want the condition to be re-evaluated and the DOM to change.
|
|
447
|
+
|
|
448
|
+
Example:
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import { component, div, button, type Handle } from '@vanijs/vani'
|
|
452
|
+
|
|
453
|
+
const TogglePanel = component((_, handle: Handle) => {
|
|
454
|
+
let open = false
|
|
455
|
+
|
|
456
|
+
const toggle = () => {
|
|
457
|
+
open = !open
|
|
458
|
+
handle.update()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return () =>
|
|
462
|
+
div(
|
|
463
|
+
button({ onclick: toggle }, open ? 'Hide details' : 'Show details'),
|
|
464
|
+
open ? div('Now you see me') : null,
|
|
465
|
+
)
|
|
466
|
+
})
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The `open` flag is local state. When it changes, you call `handle.update()` to re-render the
|
|
470
|
+
component’s subtree; the conditional element is added or removed accordingly.
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
### 7) Scheduling across independent regions
|
|
475
|
+
|
|
476
|
+
In large apps, keep each UI region as its own component root, and schedule updates explicitly. Use
|
|
477
|
+
microtasks for immediate batching and `startTransition()` for non‑urgent work. This lets you control
|
|
478
|
+
_when_ updates happen without hidden dependencies.
|
|
479
|
+
|
|
480
|
+
Strategy:
|
|
481
|
+
|
|
482
|
+
- Give each region its own `handle`.
|
|
483
|
+
- Coalesce multiple changes in the same tick into a single update per region.
|
|
484
|
+
- Use microtasks for urgent updates (input, selection).
|
|
485
|
+
- Use `startTransition()` for expensive or non‑urgent work (filters, reorders).
|
|
486
|
+
- Avoid cascading updates by keeping regions independent and coordinating through explicit APIs.
|
|
487
|
+
|
|
488
|
+
Example scheduler:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
import { startTransition, type Handle } from '@vanijs/vani'
|
|
492
|
+
|
|
493
|
+
type RegionId = 'sidebar' | 'content' | 'status'
|
|
494
|
+
|
|
495
|
+
const pending = new Set<RegionId>()
|
|
496
|
+
const handles = new Map<RegionId, Handle>()
|
|
497
|
+
|
|
498
|
+
export const registerRegion = (id: RegionId, handle: Handle) => {
|
|
499
|
+
handles.set(id, handle)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export const scheduleRegionUpdate = (id: RegionId, opts?: { transition?: boolean }) => {
|
|
503
|
+
pending.add(id)
|
|
504
|
+
|
|
505
|
+
if (opts?.transition) {
|
|
506
|
+
startTransition(flush)
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
queueMicrotask(flush)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const flush = () => {
|
|
514
|
+
for (const id of pending) {
|
|
515
|
+
handles.get(id)?.update()
|
|
516
|
+
}
|
|
517
|
+
pending.clear()
|
|
518
|
+
}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
This design keeps scheduling predictable: each region updates at most once per flush, and you can
|
|
522
|
+
decide which updates are urgent vs. deferred. If a region needs data from another, call its public
|
|
523
|
+
API first, then schedule both regions explicitly in the same flush.
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Advanced patterns
|
|
528
|
+
|
|
529
|
+
These patterns stay explicit while scaling across larger apps.
|
|
530
|
+
|
|
531
|
+
### Global state with subscriptions
|
|
532
|
+
|
|
533
|
+
Use a small store with `getState`, `setState`, and `subscribe`. Components subscribe once and call
|
|
534
|
+
`handle.update()` on changes.
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
// store.ts
|
|
538
|
+
type Listener = () => void
|
|
539
|
+
type AppState = { count: number }
|
|
540
|
+
|
|
541
|
+
let state: AppState = { count: 0 }
|
|
542
|
+
const listeners = new Set<Listener>()
|
|
543
|
+
|
|
544
|
+
export const getState = () => state
|
|
545
|
+
export const setState = (next: AppState) => {
|
|
546
|
+
state = next
|
|
547
|
+
for (const listener of listeners) listener()
|
|
548
|
+
}
|
|
549
|
+
export const subscribe = (listener: Listener) => {
|
|
550
|
+
listeners.add(listener)
|
|
551
|
+
return () => listeners.delete(listener)
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
import { component, div, button, type Handle } from '@vanijs/vani'
|
|
557
|
+
import { getState, setState, subscribe } from './store'
|
|
558
|
+
|
|
559
|
+
const Counter = component((_, handle: Handle) => {
|
|
560
|
+
handle.effect(() => subscribe(() => handle.update()))
|
|
561
|
+
|
|
562
|
+
return () => {
|
|
563
|
+
const { count } = getState()
|
|
564
|
+
return div(`Count: ${count}`, button({ onclick: () => setState({ count: count + 1 }) }, 'Inc'))
|
|
565
|
+
}
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### Data fetching + cache invalidation
|
|
570
|
+
|
|
571
|
+
Keep a simple cache and explicit invalidation. Updates are manual and predictable.
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
type Listener = () => void
|
|
575
|
+
const listeners = new Set<Listener>()
|
|
576
|
+
const cache = new Map<string, unknown>()
|
|
577
|
+
|
|
578
|
+
export const subscribe = (listener: Listener) => {
|
|
579
|
+
listeners.add(listener)
|
|
580
|
+
return () => listeners.delete(listener)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export const getCached = <T>(key: string) => cache.get(key) as T | undefined
|
|
584
|
+
|
|
585
|
+
export const refresh = async (key: string, fetcher: () => Promise<unknown>) => {
|
|
586
|
+
cache.set(key, await fetcher())
|
|
587
|
+
for (const listener of listeners) listener()
|
|
588
|
+
}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### Derived (selector) state
|
|
592
|
+
|
|
593
|
+
Compute derived values during render, or cache them when the base state changes. This keeps updates
|
|
594
|
+
explicit and avoids hidden dependencies.
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
const getVisibleItems = (items: string[], filter: string) =>
|
|
598
|
+
filter ? items.filter((item) => item.includes(filter)) : items
|
|
599
|
+
|
|
600
|
+
// In render:
|
|
601
|
+
const visible = getVisibleItems(items, filter)
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Event bus for cross-feature coordination
|
|
605
|
+
|
|
606
|
+
For decoupled features, use a tiny event bus and update explicitly when events fire.
|
|
607
|
+
|
|
608
|
+
```ts
|
|
609
|
+
type Listener = (payload?: unknown) => void
|
|
610
|
+
const listeners = new Map<string, Set<Listener>>()
|
|
611
|
+
|
|
612
|
+
export const on = (event: string, listener: Listener) => {
|
|
613
|
+
const set = listeners.get(event) ?? new Set<Listener>()
|
|
614
|
+
set.add(listener)
|
|
615
|
+
listeners.set(event, set)
|
|
616
|
+
return () => set.delete(listener)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export const emit = (event: string, payload?: unknown) => {
|
|
620
|
+
const set = listeners.get(event)
|
|
621
|
+
if (!set) return
|
|
622
|
+
for (const listener of set) listener(payload)
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## Large-scale app architecture
|
|
629
|
+
|
|
630
|
+
Vani scales best when you keep update paths explicit and module boundaries clear. The core idea is
|
|
631
|
+
to let feature modules own their local state and expose small, explicit APIs for coordination,
|
|
632
|
+
instead of reaching into each other's state or relying on global reactive graphs. At scale, treat
|
|
633
|
+
updates like messages: state lives in a module, views read from the module, and invalidation is
|
|
634
|
+
triggered by module commands.
|
|
635
|
+
|
|
636
|
+
### Suggested architecture
|
|
637
|
+
|
|
638
|
+
1. Feature modules (state + commands)
|
|
639
|
+
|
|
640
|
+
Each module exposes:
|
|
641
|
+
|
|
642
|
+
- a small state container
|
|
643
|
+
- read accessors (snapshot getters)
|
|
644
|
+
- explicit commands (mutations) that notify listeners
|
|
645
|
+
- a `subscribe(listener)` for views to bind invalidation
|
|
646
|
+
|
|
647
|
+
2. View adapters (bind handles)
|
|
648
|
+
|
|
649
|
+
Views subscribe once via `handle.effect()` and call `handle.update()` when their module notifies.
|
|
650
|
+
This keeps invalidation scoped to the subtree that owns the handle.
|
|
651
|
+
|
|
652
|
+
3. Coordinator (optional)
|
|
653
|
+
|
|
654
|
+
For cross-module workflows, add a thin coordinator that:
|
|
655
|
+
|
|
656
|
+
- orchestrates sequences (e.g. save → refresh → notify)
|
|
657
|
+
- calls public APIs of each module
|
|
658
|
+
- never accesses private state directly
|
|
659
|
+
|
|
660
|
+
4. Stable, explicit contracts
|
|
661
|
+
|
|
662
|
+
Use interfaces, simple message payloads, or callbacks to avoid implicit coupling. If one feature
|
|
663
|
+
needs another to update, it calls that module's exported command (or `invalidate()` helper) rather
|
|
664
|
+
than mutating shared data.
|
|
665
|
+
|
|
666
|
+
### Example: Feature module with explicit invalidation
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
import { component, div, type Handle, type Component } from '@vanijs/vani'
|
|
670
|
+
|
|
671
|
+
type User = { id: string; name: string }
|
|
672
|
+
|
|
673
|
+
export type UsersFeatureApi = {
|
|
674
|
+
getUsers: () => User[]
|
|
675
|
+
setUsers: (next: User[]) => void
|
|
676
|
+
refreshUsers: () => Promise<void>
|
|
677
|
+
subscribe: (listener: () => void) => () => void
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export const createUsersFeature = (): { api: UsersFeatureApi; View: Component } => {
|
|
681
|
+
let users: User[] = []
|
|
682
|
+
const listeners = new Set<() => void>()
|
|
683
|
+
|
|
684
|
+
const notify = () => {
|
|
685
|
+
for (const listener of listeners) listener()
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const api: UsersFeatureApi = {
|
|
689
|
+
getUsers: () => users,
|
|
690
|
+
setUsers: (next) => {
|
|
691
|
+
users = next
|
|
692
|
+
notify()
|
|
693
|
+
},
|
|
694
|
+
refreshUsers: async () => {
|
|
695
|
+
const response = await fetch('/api/users')
|
|
696
|
+
const data = (await response.json()) as User[]
|
|
697
|
+
api.setUsers(data)
|
|
698
|
+
},
|
|
699
|
+
subscribe: (listener) => {
|
|
700
|
+
listeners.add(listener)
|
|
701
|
+
return () => listeners.delete(listener)
|
|
702
|
+
},
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const View = component((_, handle: Handle) => {
|
|
706
|
+
handle.effect(() => api.subscribe(() => handle.update()))
|
|
707
|
+
return () => div(api.getUsers().map((user) => div(user.name)))
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
return { api, View }
|
|
711
|
+
}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
### Example: Coordinator calling explicit APIs
|
|
715
|
+
|
|
716
|
+
```ts
|
|
717
|
+
import type { UsersFeatureApi } from './users-feature'
|
|
718
|
+
|
|
719
|
+
type Coordinator = {
|
|
720
|
+
onUserSaved: () => Promise<void>
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export const createCoordinator = (users: UsersFeatureApi): Coordinator => {
|
|
724
|
+
return {
|
|
725
|
+
onUserSaved: async () => {
|
|
726
|
+
await users.refreshUsers()
|
|
727
|
+
},
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Example: Scoped invalidation by key
|
|
733
|
+
|
|
734
|
+
When a large list exists, invalidate only the affected rows.
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
import { component, div, type Handle } from '@vanijs/vani'
|
|
738
|
+
|
|
739
|
+
const rowHandles = new Map<string, Handle>()
|
|
740
|
+
|
|
741
|
+
export const bindUserRow = (id: string, handle: Handle) => {
|
|
742
|
+
rowHandles.set(id, handle)
|
|
743
|
+
return () => rowHandles.delete(id)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export const invalidateUserRow = (id: string) => {
|
|
747
|
+
rowHandles.get(id)?.update()
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export const UserRow = component<{ id: string; name: string }>((props, handle) => {
|
|
751
|
+
handle.effect(() => bindUserRow(props.id, handle))
|
|
752
|
+
return () => div(props.name)
|
|
753
|
+
})
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
### Explicit batching (optional)
|
|
757
|
+
|
|
758
|
+
If you dispatch many invalidations in a single tick, queue them and update once per handle.
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
import type { Handle } from '@vanijs/vani'
|
|
762
|
+
|
|
763
|
+
const pending = new Set<Handle>()
|
|
764
|
+
let scheduled = false
|
|
765
|
+
|
|
766
|
+
export const queueUpdate = (handle: Handle) => {
|
|
767
|
+
pending.add(handle)
|
|
768
|
+
if (scheduled) return
|
|
769
|
+
scheduled = true
|
|
770
|
+
queueMicrotask(() => {
|
|
771
|
+
scheduled = false
|
|
772
|
+
for (const item of pending) item.update()
|
|
773
|
+
pending.clear()
|
|
774
|
+
})
|
|
775
|
+
}
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
### Challenges with manual invalidation at scale
|
|
779
|
+
|
|
780
|
+
- Update fan-out: one action may need to notify several modules; keep this explicit via a
|
|
781
|
+
coordinator instead of hidden subscriptions.
|
|
782
|
+
- Over-invalidating: calling `handle.update()` too broadly can re-render large subtrees; prefer
|
|
783
|
+
small, keyed targets (row components, feature roots).
|
|
784
|
+
- Under-invalidating: missing an update call leaves views stale; treat "update after state change"
|
|
785
|
+
as part of the module contract and centralize commands.
|
|
786
|
+
- Ordering and race conditions: when multiple modules depend on shared data, update data first, then
|
|
787
|
+
invalidate in a predictable order; avoid interleaving async updates without coordination.
|
|
788
|
+
- Lifecycle leaks: if a handle isn't unsubscribed, updates keep firing; ensure `subscribe()` returns
|
|
789
|
+
a cleanup and is wired through `handle.effect()`.
|
|
790
|
+
- Debugging update paths: without implicit reactivity, you must trace who called `update()`. Keep
|
|
791
|
+
module APIs narrow, name update methods clearly (`refreshUsers`, `invalidateSearch`), and consider
|
|
792
|
+
instrumentation (log or wrap invalidation helpers).
|
|
793
|
+
|
|
794
|
+
Vani trades automatic coordination for transparency. In large apps, that means you should invest in
|
|
795
|
+
clear module boundaries, explicit cross-module APIs, and small invalidation targets.
|
|
796
|
+
|
|
101
797
|
---
|
|
102
798
|
|
|
103
799
|
## API Reference (with examples)
|
|
@@ -203,6 +899,26 @@ import { div, span, button, input } from '@vanijs/vani'
|
|
|
203
899
|
div(span('Label'), input({ type: 'text' }), button({ onclick: () => {} }, 'Submit'))
|
|
204
900
|
```
|
|
205
901
|
|
|
902
|
+
### SVG icons (Lucide)
|
|
903
|
+
|
|
904
|
+
Vani can render SVG strings directly using `renderSvgString()`. With `lucide-static`, import just
|
|
905
|
+
the icon you need (tree-shakable) and render it with explicit sizing and class names.
|
|
906
|
+
|
|
907
|
+
```ts
|
|
908
|
+
import { component } from '@vanijs/vani'
|
|
909
|
+
import { renderSvgString } from '@vanijs/vani/svg'
|
|
910
|
+
import { Github } from 'lucide-static'
|
|
911
|
+
|
|
912
|
+
const GithubLink = component(() => {
|
|
913
|
+
return () =>
|
|
914
|
+
renderSvgString(Github, {
|
|
915
|
+
size: 16,
|
|
916
|
+
className: 'h-4 w-4',
|
|
917
|
+
attributes: { 'aria-hidden': 'true' },
|
|
918
|
+
})
|
|
919
|
+
})
|
|
920
|
+
```
|
|
921
|
+
|
|
206
922
|
### `classNames(...classes)`
|
|
207
923
|
|
|
208
924
|
Utility for composing class names:
|
|
@@ -211,7 +927,7 @@ Utility for composing class names:
|
|
|
211
927
|
import { classNames, div } from '@vanijs/vani'
|
|
212
928
|
|
|
213
929
|
div({
|
|
214
|
-
className: classNames('base', { active: true }, ['p-2', 'rounded']),
|
|
930
|
+
className: classNames('base', { active: true }, ['p-2', 'rounded-xl']),
|
|
215
931
|
})
|
|
216
932
|
```
|
|
217
933
|
|
|
@@ -237,7 +953,8 @@ Effects are explicit and can return a cleanup function.
|
|
|
237
953
|
If you plan to use vani for a SSR/SSG application, you should use effects to run client-only code
|
|
238
954
|
such as accessing the window object, accessing the DOM, etc.
|
|
239
955
|
|
|
240
|
-
Effects are very simple
|
|
956
|
+
Effects are very simple and run once during component setup (the component function run). They do
|
|
957
|
+
not re-run on every `handle.update()`; updates only call the render function.
|
|
241
958
|
|
|
242
959
|
```ts
|
|
243
960
|
import { component, div } from '@vanijs/vani'
|
|
@@ -303,7 +1020,8 @@ const App = component(
|
|
|
303
1020
|
)
|
|
304
1021
|
```
|
|
305
1022
|
|
|
306
|
-
|
|
1023
|
+
In DOM mode, the fallback is rendered until the component is ready. In SSR mode, async components
|
|
1024
|
+
are awaited, so the fallback only renders for `clientOnly` components.
|
|
307
1025
|
|
|
308
1026
|
---
|
|
309
1027
|
|
|
@@ -342,8 +1060,47 @@ Use `renderToString()` on the server, then `hydrateToDOM()` on the client.
|
|
|
342
1060
|
|
|
343
1061
|
Use `renderToString()` at build time to generate a static `index.html`, then hydrate on the client.
|
|
344
1062
|
|
|
345
|
-
**Important:** Hydration only binds to anchors. It does not render or start
|
|
346
|
-
`handle.update()` to activate the UI.
|
|
1063
|
+
**Important:** Hydration only binds to anchors for normal components. It does not render or start
|
|
1064
|
+
effects until you call `handle.update()` to activate the UI. Components marked `clientOnly: true` do
|
|
1065
|
+
render on the client during hydration.
|
|
1066
|
+
|
|
1067
|
+
---
|
|
1068
|
+
|
|
1069
|
+
## Selective Hydration
|
|
1070
|
+
|
|
1071
|
+
You can hydrate a full page but only **activate** the parts that need interactivity. Since
|
|
1072
|
+
`hydrateToDOM()` returns handles, you choose which ones to `update()`.
|
|
1073
|
+
|
|
1074
|
+
Example: hydrate everything, activate only the header.
|
|
1075
|
+
|
|
1076
|
+
```ts
|
|
1077
|
+
import { hydrateToDOM, type ComponentRef } from '@vanijs/vani'
|
|
1078
|
+
import { Header } from './header'
|
|
1079
|
+
import { Main } from './main'
|
|
1080
|
+
import { Footer } from './footer'
|
|
1081
|
+
|
|
1082
|
+
const headerRef: ComponentRef = { current: null }
|
|
1083
|
+
const root = document.getElementById('app')!
|
|
1084
|
+
|
|
1085
|
+
// Must match server render order.
|
|
1086
|
+
hydrateToDOM([Header({ ref: headerRef }), Main(), Footer()], root)
|
|
1087
|
+
|
|
1088
|
+
// Activate only the header.
|
|
1089
|
+
headerRef.current?.update()
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
Alternative: split the page into separate roots and hydrate only the interactive region.
|
|
1093
|
+
|
|
1094
|
+
```ts
|
|
1095
|
+
const headerRoot = document.getElementById('header-root')!
|
|
1096
|
+
const [headerHandle] = hydrateToDOM([Header()], headerRoot)
|
|
1097
|
+
headerHandle.update()
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
Notes:
|
|
1101
|
+
|
|
1102
|
+
- Non‑updated components remain inert (no handlers/effects) until you call `update()`.
|
|
1103
|
+
- The hydration list must match the server render order for that root.
|
|
347
1104
|
|
|
348
1105
|
---
|
|
349
1106
|
|
|
@@ -368,6 +1125,24 @@ This avoids slow DOM diffing and keeps behavior explicit.
|
|
|
368
1125
|
|
|
369
1126
|
---
|
|
370
1127
|
|
|
1128
|
+
## Other Resources
|
|
1129
|
+
|
|
1130
|
+
### Configuring Tailwind CSS Intellisense (VSCode)
|
|
1131
|
+
|
|
1132
|
+
In order to have proper Tailwind CSS Intellisense code completion and hover documentation with Vani,
|
|
1133
|
+
you need to configure the following settings in your `.vscode/settings.json` file:
|
|
1134
|
+
|
|
1135
|
+
```json
|
|
1136
|
+
{
|
|
1137
|
+
"tailwindCSS.experimental.classRegex": [
|
|
1138
|
+
["(?:tw|clsx|cn)\\(([^;]*)[\\);]", "[`'\"`]([^'\"`;]*)[`'\"`]"],
|
|
1139
|
+
"(?:className)=\\s*(?:\"|'|{`)([^(?:\"|'|`})]*)",
|
|
1140
|
+
"(?:className):\\s*(?:\"|'|{`)([^(?:\"|'|`})]*)"
|
|
1141
|
+
],
|
|
1142
|
+
"tailwindCSS.classAttributes": ["class", "classes", "className", "classNames"]
|
|
1143
|
+
}
|
|
1144
|
+
```
|
|
1145
|
+
|
|
371
1146
|
## License
|
|
372
1147
|
|
|
373
1148
|
MIT
|