@vanijs/vani 0.3.0 → 0.5.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 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, and not compilerdriven. Components own a
8
- DOM subtree delimited by anchors and only update when you explicitly ask them to.
7
+ Vani is **not** a Virtual DOM, not reactive‑by‑default (signals are optin), 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([Counter()], appRoot)
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([Counter()], document.getElementById('app')!)
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([VaniCounter()], containerRef.current)
179
+ vaniHandlesRef.current = renderToDOM(VaniCounter(), containerRef.current)
147
180
 
148
181
  // Cleanup when React unmounts
149
182
  return () => {
@@ -160,6 +193,148 @@ 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
+ - `onMount(getNodes, parent)` gives access to the rendered DOM subtree without ref plumbing,
230
+ aligning with explicit updates and simplifying vanilla JS integrations
231
+ - `className` accepts string, array, and object forms for ergonomic composition
232
+ - ESM-first and designed to run in any modern environment
233
+
234
+ ### What Vani is NOT
235
+
236
+ - ❌ Not a Virtual DOM
237
+ - ❌ Not reactive-by-default (signals are opt-in)
238
+ - ❌ Not JSX-mandatory (optional adapter)
239
+ - ❌ Not compiler-driven
240
+ - ❌ Not a template language
241
+ - ❌ Not a framework that guesses intent
242
+
243
+ ### Why not Web Components (yet)
244
+
245
+ Vani does not use Web Components today because the developer ergonomics are still rough and SSR
246
+ support is a key goal. We may revisit this if Web Components bring clear benefits without harming
247
+ productivity and cross-browser compatibility.
248
+
249
+ ### Comparison with popular frameworks
250
+
251
+ | Feature / Framework | Vani | React | Vue | Svelte | Solid |
252
+ | ---------------------- | ---- | ----- | --- | ------ | ----- |
253
+ | Virtual DOM | ❌ | ✅ | ✅ | ❌ | ❌ |
254
+ | Implicit reactivity | ❌ | ⚠️ | ✅ | ✅ | ✅ |
255
+ | Compiler required | ❌ | ❌ | ❌ | ✅ | ❌ |
256
+ | JSX required | ❌ | ✅ | ❌ | ❌ | ❌ |
257
+ | Explicit updates | ✅ | ❌ | ❌ | ❌ | ❌ |
258
+ | Leaf-only updates | ✅ | ❌ | ❌ | ❌ | ❌ |
259
+ | Runtime-only | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ |
260
+ | SSR without heuristics | ✅ | ❌ | ❌ | ❌ | ❌ |
261
+ | Dependency-free core | ✅ | ❌ | ❌ | ❌ | ❌ |
262
+
263
+ ⚠️ = partially / indirectly supported / average
264
+
265
+ The strength of Vani is its predictability and simplicity, while other frameworks focus on developer
266
+ productivity and ease of use, handling a lot of complexity behind the scenes automatically.
267
+
268
+ ### Vani's sweet spot
269
+
270
+ ✅ Perfect for:
271
+
272
+ - Dashboard widgets
273
+ - Micro-frontends
274
+ - Live-coding in the browser
275
+ - Embeddable components in other frameworks
276
+ - Performance-critical UIs where you need exact control
277
+ - Server-rendered sites
278
+ - Learning UI fundamentals (no magic, direct DOM)
279
+ - Lightweight SPAs or small Multi-Page Applications
280
+
281
+ ❌ Not ideal for:
282
+
283
+ - Large, complex web applications with many interrelated states
284
+ - Teams that want framework conventions to handle complexity
285
+ - Projects needing a mature ecosystem
286
+
287
+ (at least not yet)
288
+
289
+ ### Mental model
290
+
291
+ Think of Vani as:
292
+
293
+ > **“Manually invalidated, DOM-owned UI subtrees.”**
294
+
295
+ You decide:
296
+
297
+ - when something updates
298
+ - how much updates
299
+ - and why it updates
300
+
301
+ Nothing else happens behind your back.
302
+
303
+ ### Who Vani is for
304
+
305
+ Vani is a good fit if you value:
306
+
307
+ - full control over rendering
308
+ - predictable performance
309
+ - small runtimes
310
+ - explicit data flow
311
+ - SSR without complexity
312
+ - understanding your tools deeply
313
+
314
+ It is **not** optimized for:
315
+
316
+ - rapid prototyping
317
+ - beginners
318
+ - implicit magic
319
+ - large teams that rely on conventions
320
+
321
+ ### Status
322
+
323
+ Vani is experimental and evolving. The core architecture is intentionally small and stable.
324
+
325
+ Expect:
326
+
327
+ - iteration
328
+ - refinement
329
+ - careful additions
330
+
331
+ Not:
332
+
333
+ - rapid feature creep
334
+ - breaking conceptual changes
335
+
336
+ ---
337
+
163
338
  ## Core Concepts
164
339
 
165
340
  ### 1) Components are functions
@@ -213,23 +388,267 @@ Each component owns a DOM range delimited by anchors:
213
388
 
214
389
  Updates replace only the DOM between anchors.
215
390
 
391
+ ### 3.1) Nested component hierarchies (isolated subtrees)
392
+
393
+ To build a nested tree, have a parent return child component instances as part of its render output.
394
+ Each component instance creates its own anchor range, so parent and child updates stay isolated.
395
+
396
+ #### How DOM isolation works
397
+
398
+ When you nest components, each component gets its own pair of `<!--vani:start-->` and
399
+ `<!--vani:end-->` comment anchors. The DOM structure for a parent containing a child looks like:
400
+
401
+ ```html
402
+ <!--vani:start-->
403
+ <!-- Parent start -->
404
+ <div>
405
+ <div>Parent title</div>
406
+ <button>Rename parent</button>
407
+ <!--vani:start-->
408
+ <!-- Child start -->
409
+ <div>
410
+ Child clicks: 0
411
+ <button>Click child</button>
412
+ </div>
413
+ <!--vani:end-->
414
+ <!-- Child end -->
415
+ </div>
416
+ <!--vani:end-->
417
+ <!-- Parent end -->
418
+ ```
419
+
420
+ When the parent updates, Vani replaces the DOM between the parent's anchors **but preserves the
421
+ child's anchors and their contents**. When the child updates, only the DOM between the child's
422
+ anchors is replaced. This anchor-based isolation is automatic—no special API is needed.
423
+
424
+ #### Update isolation rules
425
+
426
+ - **Parent update**: replaces parent’s subtree but leaves nested child anchor ranges intact.
427
+ - **Child update**: replaces only the child’s subtree; parent is unaffected.
428
+ - **Re-render with new props**: if the parent re-renders and returns the same child component with
429
+ new props, the child’s component instance is preserved (not recreated) and the child can read the
430
+ new props on its next `update()`.
431
+
432
+ #### Basic parent-child example
433
+
434
+ ```ts
435
+ import { component, div, button, type Handle, type ComponentRef } from '@vanijs/vani'
436
+
437
+ const Child = component<{ label: string }>((props, handle: Handle) => {
438
+ let clicks = 0
439
+ return () =>
440
+ div(
441
+ `${props.label} clicks: ${clicks}`,
442
+ button(
443
+ {
444
+ onclick: () => {
445
+ clicks += 1
446
+ handle.update()
447
+ },
448
+ },
449
+ 'Click child',
450
+ ),
451
+ )
452
+ })
453
+
454
+ const Parent = component((_, handle: Handle) => {
455
+ let title = 'Parent'
456
+ const childRef: ComponentRef = { current: null }
457
+
458
+ const rename = () => {
459
+ title = title === 'Parent' ? 'Parent (renamed)' : 'Parent'
460
+ handle.update()
461
+ }
462
+
463
+ return () =>
464
+ div(
465
+ div(`Title: ${title}`),
466
+ button({ onclick: rename }, 'Rename parent'),
467
+ // Nested component subtree (isolated updates)
468
+ Child({ ref: childRef, label: 'Child' }),
469
+ )
470
+ })
471
+ ```
472
+
473
+ When you click "Rename parent", only the parent subtree updates. When you click "Click child", only
474
+ the child subtree updates.
475
+
476
+ #### Deeper nesting (grandparent → parent → child)
477
+
478
+ You can nest components to any depth. Each level maintains its own isolated subtree:
479
+
480
+ ```ts
481
+ import { component, div, button, type Handle } from '@vanijs/vani'
482
+
483
+ const GrandChild = component<{ name: string }>((props, handle: Handle) => {
484
+ let value = 0
485
+ return () =>
486
+ div(
487
+ `GrandChild (${props.name}): ${value}`,
488
+ button(
489
+ {
490
+ onclick: () => {
491
+ value += 1
492
+ handle.update()
493
+ },
494
+ },
495
+ '+',
496
+ ),
497
+ )
498
+ })
499
+
500
+ const Child = component<{ id: number }>((props, handle: Handle) => {
501
+ let label = `Child #${props.id}`
502
+ return () =>
503
+ div(
504
+ div(label),
505
+ button(
506
+ {
507
+ onclick: () => {
508
+ label += '!'
509
+ handle.update()
510
+ },
511
+ },
512
+ 'Edit label',
513
+ ),
514
+ GrandChild({ name: `gc-${props.id}` }),
515
+ )
516
+ })
517
+
518
+ const Parent = component((_, handle: Handle) => {
519
+ let count = 2
520
+ return () =>
521
+ div(
522
+ div(`Parent has ${count} children`),
523
+ button(
524
+ {
525
+ onclick: () => {
526
+ count += 1
527
+ handle.update()
528
+ },
529
+ },
530
+ 'Add child',
531
+ ),
532
+ ...Array.from({ length: count }, (_, i) => Child({ id: i })),
533
+ )
534
+ })
535
+ ```
536
+
537
+ Each `GrandChild` can update independently of its `Child`, and each `Child` can update independently
538
+ of the `Parent`. The anchor isolation means you can have deeply nested trees without cascading
539
+ re-renders.
540
+
541
+ #### Passing props and callbacks (prop drilling)
542
+
543
+ Props flow down explicitly. If a child needs data from an ancestor, pass it through props:
544
+
545
+ ```ts
546
+ import { component, div, button, type Handle } from '@vanijs/vani'
547
+
548
+ const Display = component<{ value: number }>((props) => {
549
+ return () => div(`Current value: ${props.value}`)
550
+ })
551
+
552
+ const Controls = component<{ onIncrement: () => void }>((props) => {
553
+ return () => button({ onclick: props.onIncrement }, 'Increment')
554
+ })
555
+
556
+ const App = component((_, handle: Handle) => {
557
+ let count = 0
558
+ const increment = () => {
559
+ count += 1
560
+ handle.update()
561
+ }
562
+ return () => div(Display({ value: count }), Controls({ onIncrement: increment }))
563
+ })
564
+ ```
565
+
566
+ When `increment` is called, the `App` re-renders. Because `Display` and `Controls` are nested
567
+ components with their own anchor ranges, their internal state (if any) is preserved. The new `value`
568
+ prop is available to `Display` on the next render.
569
+
570
+ #### Updating children from the parent via refs
571
+
572
+ If you need to trigger a child update without re-rendering the parent, store a `ComponentRef` and
573
+ call `update()` on it directly:
574
+
575
+ ```ts
576
+ import { component, div, button, type Handle, type ComponentRef } from '@vanijs/vani'
577
+
578
+ const Counter = component<{ start: number }>((props, handle: Handle) => {
579
+ let count = props.start
580
+ return () =>
581
+ div(
582
+ `Count: ${count}`,
583
+ button(
584
+ {
585
+ onclick: () => {
586
+ count += 1
587
+ handle.update()
588
+ },
589
+ },
590
+ '+',
591
+ ),
592
+ )
593
+ })
594
+
595
+ const Dashboard = component(() => {
596
+ const counterRef: ComponentRef = { current: null }
597
+
598
+ const resetCounter = () => {
599
+ // Update only the Counter subtree, not the Dashboard
600
+ counterRef.current?.update()
601
+ }
602
+
603
+ return () =>
604
+ div(
605
+ Counter({ ref: counterRef, start: 0 }),
606
+ button({ onclick: resetCounter }, 'Refresh counter'),
607
+ )
608
+ })
609
+ ```
610
+
611
+ Clicking "Refresh counter" calls `counterRef.current?.update()`, which re-renders only the `Counter`
612
+ subtree. The `Dashboard` itself does not re-render.
613
+
614
+ #### Summary
615
+
616
+ - Nesting is standard component composition—no special API.
617
+ - Each component instance owns an anchor-delimited DOM range.
618
+ - Updates are isolated: a component’s `handle.update()` only affects its own subtree.
619
+ - Props and callbacks flow down explicitly (prop drilling).
620
+ - Use `ComponentRef` to update children without re-rendering parents.
621
+
216
622
  ### 4) Lists and item-level updates
217
623
 
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.
624
+ Lists scale well in Vani when each item is its own component. Every item owns a tiny subtree and can
625
+ update itself (or be updated via a ref) without touching siblings. Use `key` to preserve identity
626
+ across reorders.
221
627
 
222
628
  Key ideas:
223
629
 
224
630
  - Represent list data by id (Map or array + id).
225
631
  - Render each row as a keyed component.
226
632
  - 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).
633
+ - Call `renderKeyedChildren()` for structural list changes (add/remove/reorder).
634
+ - Keys are only respected by `renderKeyedChildren()`. Passing keyed children into `div()` or another
635
+ component does not trigger keyed diffing.
228
636
 
229
637
  Example:
230
638
 
231
639
  ```ts
232
- import { component, ul, li, input, button, type Handle, type ComponentRef } from '@vanijs/vani'
640
+ import {
641
+ component,
642
+ div,
643
+ ul,
644
+ li,
645
+ input,
646
+ button,
647
+ renderKeyedChildren,
648
+ type ComponentRef,
649
+ type DomRef,
650
+ type Handle,
651
+ } from '@vanijs/vani'
233
652
 
234
653
  type Todo = { id: string; text: string; done: boolean }
235
654
 
@@ -267,6 +686,7 @@ const List = component((_, handle: Handle) => {
267
686
  ])
268
687
 
269
688
  const refs = new Map<string, ComponentRef>()
689
+ const listRef: DomRef<HTMLUListElement> = { current: null }
270
690
  const getRef = (id: string) => {
271
691
  let ref = refs.get(id)
272
692
  if (!ref) {
@@ -293,32 +713,43 @@ const List = component((_, handle: Handle) => {
293
713
 
294
714
  const rename = (id: string, text: string) => updateItem(id, { text })
295
715
 
716
+ const renderRows = () => {
717
+ if (!listRef.current) return
718
+ renderKeyedChildren(
719
+ listRef.current,
720
+ order.map((id) =>
721
+ Row({
722
+ key: id,
723
+ ref: getRef(id),
724
+ id,
725
+ getItem,
726
+ onToggle: toggle,
727
+ onRename: rename,
728
+ }),
729
+ ),
730
+ )
731
+ }
732
+ handle.onBeforeMount(() => {
733
+ queueMicrotask(renderRows)
734
+ })
735
+
296
736
  const add = (text: string) => {
297
737
  const id = String(order.length + 1)
298
738
  items.set(id, { id, text, done: false })
299
739
  order = [...order, id]
300
- handle.update()
740
+ renderRows()
301
741
  }
302
742
 
303
743
  const remove = (id: string) => {
304
744
  items.delete(id)
305
745
  refs.delete(id)
306
746
  order = order.filter((value) => value !== id)
307
- handle.update()
747
+ renderRows()
308
748
  }
309
749
 
310
750
  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
- ),
751
+ div(
752
+ ul({ ref: listRef }),
322
753
  button({ onclick: () => add('New item') }, 'Add'),
323
754
  button(
324
755
  {
@@ -334,7 +765,7 @@ const List = component((_, handle: Handle) => {
334
765
  ```
335
766
 
336
767
  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.
768
+ structural changes are handled by `renderKeyedChildren()` on the list container.
338
769
 
339
770
  ---
340
771
 
@@ -471,7 +902,31 @@ component’s subtree; the conditional element is added or removed accordingly.
471
902
 
472
903
  ---
473
904
 
474
- ### 7) Scheduling across independent regions
905
+ ### 7) Signals (optional)
906
+
907
+ Signals are opt-in fine-grained state. They do **not** auto-render components; you either bind them
908
+ directly to DOM helpers like `text()` / `attr()` or explicitly call `handle.update()`.
909
+
910
+ Example:
911
+
912
+ ```ts
913
+ import { component, button, div, signal, text } from '@vanijs/vani'
914
+
915
+ const Counter = component(() => {
916
+ const [count, setCount] = signal(0)
917
+ return () =>
918
+ div(
919
+ text(() => `Count: ${count()}`),
920
+ button({ onclick: () => setCount((value) => value + 1) }, 'Inc'),
921
+ )
922
+ })
923
+ ```
924
+
925
+ Use `derive()` for computed getters and `effect()` for side effects tied to signals.
926
+
927
+ ---
928
+
929
+ ### 8) Scheduling across independent regions
475
930
 
476
931
  In large apps, keep each UI region as its own component root, and schedule updates explicitly. Use
477
932
  microtasks for immediate batching and `startTransition()` for non‑urgent work. This lets you control
@@ -522,6 +977,82 @@ This design keeps scheduling predictable: each region updates at most once per f
522
977
  decide which updates are urgent vs. deferred. If a region needs data from another, call its public
523
978
  API first, then schedule both regions explicitly in the same flush.
524
979
 
980
+ ### Explicit multi-region scheduling strategy
981
+
982
+ When multiple independent UI regions update in the same tick, prefer a central scheduler that:
983
+
984
+ - deduplicates updates per region (one update per flush)
985
+ - batches synchronous state changes into a microtask
986
+ - separates urgent vs. non‑urgent work (`queueMicrotask` vs. `startTransition`)
987
+ - avoids cascades by flushing a single pass and re‑queueing if new work appears
988
+
989
+ Example:
990
+
991
+ ```ts
992
+ import { startTransition, type Handle } from '@vanijs/vani'
993
+
994
+ type RegionId = 'header' | 'content' | 'sidebar' | 'status'
995
+ type Priority = 'urgent' | 'transition'
996
+
997
+ const handles = new Map<RegionId, Handle>()
998
+ const pendingUrgent = new Set<RegionId>()
999
+ const pendingTransition = new Set<RegionId>()
1000
+ let microtaskScheduled = false
1001
+ let transitionScheduled = false
1002
+
1003
+ export const registerRegion = (id: RegionId, handle: Handle) => {
1004
+ handles.set(id, handle)
1005
+ }
1006
+
1007
+ export const scheduleRegion = (id: RegionId, priority: Priority = 'urgent') => {
1008
+ if (priority === 'transition') {
1009
+ pendingTransition.add(id)
1010
+ if (!transitionScheduled) {
1011
+ transitionScheduled = true
1012
+ startTransition(flushTransition)
1013
+ }
1014
+ return
1015
+ }
1016
+
1017
+ pendingUrgent.add(id)
1018
+ if (!microtaskScheduled) {
1019
+ microtaskScheduled = true
1020
+ queueMicrotask(flushUrgent)
1021
+ }
1022
+ }
1023
+
1024
+ const flushUrgent = () => {
1025
+ microtaskScheduled = false
1026
+ for (const id of pendingUrgent) {
1027
+ handles.get(id)?.update()
1028
+ }
1029
+ pendingUrgent.clear()
1030
+
1031
+ // If urgent updates caused more urgent work, queue another microtask.
1032
+ if (pendingUrgent.size > 0 && !microtaskScheduled) {
1033
+ microtaskScheduled = true
1034
+ queueMicrotask(flushUrgent)
1035
+ }
1036
+ }
1037
+
1038
+ const flushTransition = () => {
1039
+ transitionScheduled = false
1040
+ for (const id of pendingTransition) {
1041
+ handles.get(id)?.update()
1042
+ }
1043
+ pendingTransition.clear()
1044
+ }
1045
+ ```
1046
+
1047
+ Guidelines:
1048
+
1049
+ - **Priority conflicts**: if a region is queued in both sets, let urgent win and clear it from
1050
+ transition during flush.
1051
+ - **Cascading updates**: if an update triggers more updates, re‑queue explicitly rather than looping
1052
+ synchronously.
1053
+ - **Predictability**: keep region IDs stable and avoid cross‑region reads during render; use
1054
+ coordinators to update shared data first, then schedule regions in a known order.
1055
+
525
1056
  ---
526
1057
 
527
1058
  ## Advanced patterns
@@ -557,7 +1088,7 @@ import { component, div, button, type Handle } from '@vanijs/vani'
557
1088
  import { getState, setState, subscribe } from './store'
558
1089
 
559
1090
  const Counter = component((_, handle: Handle) => {
560
- handle.effect(() => subscribe(() => handle.update()))
1091
+ handle.onBeforeMount(() => subscribe(() => handle.update()))
561
1092
 
562
1093
  return () => {
563
1094
  const { count } = getState()
@@ -627,42 +1158,78 @@ export const emit = (event: string, payload?: unknown) => {
627
1158
 
628
1159
  ## Large-scale app architecture
629
1160
 
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.
1161
+ Vani scales best when update paths stay explicit and module boundaries stay tight. Treat updates as
1162
+ messages: state lives in a feature module, views read snapshots, and invalidation is triggered by
1163
+ that module's commands. This prevents hidden dependencies and makes cross-feature coordination an
1164
+ explicit, testable surface.
1165
+
1166
+ ### Architecting large-scale, coordinated modules
1167
+
1168
+ Use explicit feature modules with clear APIs, bind views to module subscriptions, and coordinate
1169
+ cross-module workflows through a small coordinator or typed event channel. Keep invalidation scoped
1170
+ to feature roots or keyed row components, and batch updates per handle. Avoid implicit dependencies
1171
+ by never mutating another module's state directly; instead call exported commands or emit events. At
1172
+ scale, manual invalidation challenges include fan-out, over- or under-invalidating, update
1173
+ ordering/races, stale reads during transitions, lifecycle leaks, and lack of observability. Mitigate
1174
+ with explicit contracts, centralized command surfaces, predictable ordering, cleanup via
1175
+ `handle.onBeforeMount()`, and lightweight logging around invalidation helpers.
1176
+
1177
+ Architecture sketch:
1178
+
1179
+ ```
1180
+ [UI/View A] --subscribe--> [Feature A]
1181
+ | |
1182
+ | update() | commands
1183
+ v v
1184
+ [Handle A] [Coordinator] ----> [Feature B]
1185
+ ^ | |
1186
+ | update() | events | notify
1187
+ [UI/View B] --subscribe--> [Feature B] <---------+
1188
+ ```
635
1189
 
636
1190
  ### Suggested architecture
637
1191
 
638
1192
  1. Feature modules (state + commands)
639
1193
 
640
- Each module exposes:
1194
+ Each module owns its state and exposes a small API:
641
1195
 
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
1196
+ - snapshot getters (`getState`, `getUsers`, `getFilters`)
1197
+ - commands that mutate state (`setUsers`, `applyFilter`)
1198
+ - `subscribe(listener)` to notify views of invalidation
1199
+ - optional selectors for derived data
646
1200
 
647
1201
  2. View adapters (bind handles)
648
1202
 
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.
1203
+ Views subscribe once via `handle.onBeforeMount()` and call `handle.update()` when their module
1204
+ notifies. This keeps invalidation scoped to the subtree that owns the handle.
651
1205
 
652
- 3. Coordinator (optional)
1206
+ 3. Coordinator or message hub
653
1207
 
654
- For cross-module workflows, add a thin coordinator that:
1208
+ For workflows that span multiple modules, add a thin coordinator that:
655
1209
 
656
- - orchestrates sequences (e.g. save → refresh → notify)
1210
+ - orchestrates sequences (save → refresh → notify)
657
1211
  - calls public APIs of each module
658
- - never accesses private state directly
1212
+ - never reaches into private state
1213
+ - batches invalidations when multiple modules change together
659
1214
 
660
1215
  4. Stable, explicit contracts
661
1216
 
662
- Use interfaces, simple message payloads, or callbacks to avoid implicit coupling. If one feature
1217
+ Use interfaces, small message payloads, or callbacks to avoid implicit coupling. If one feature
663
1218
  needs another to update, it calls that module's exported command (or `invalidate()` helper) rather
664
1219
  than mutating shared data.
665
1220
 
1221
+ ### Cross-module coordination patterns
1222
+
1223
+ Pick one, keep it explicit, and avoid hidden dependencies:
1224
+
1225
+ - **Coordinator**: a domain-level workflow function that calls module commands in order.
1226
+ - **Event channel**: a small bus where modules emit typed events and subscribers decide how to
1227
+ update; views still call `handle.update()` explicitly.
1228
+ - **Shared readonly data**: for truly global data, use a shared store with strict write APIs and
1229
+ localized subscriptions.
1230
+
1231
+ The goal is to keep "who invalidates whom" visible at the call site.
1232
+
666
1233
  ### Example: Feature module with explicit invalidation
667
1234
 
668
1235
  ```ts
@@ -703,7 +1270,7 @@ export const createUsersFeature = (): { api: UsersFeatureApi; View: Component }
703
1270
  }
704
1271
 
705
1272
  const View = component((_, handle: Handle) => {
706
- handle.effect(() => api.subscribe(() => handle.update()))
1273
+ handle.onBeforeMount(() => api.subscribe(() => handle.update()))
707
1274
  return () => div(api.getUsers().map((user) => div(user.name)))
708
1275
  })
709
1276
 
@@ -729,6 +1296,33 @@ export const createCoordinator = (users: UsersFeatureApi): Coordinator => {
729
1296
  }
730
1297
  ```
731
1298
 
1299
+ ### Example: Event channel for cross-feature updates
1300
+
1301
+ ```ts
1302
+ type EventMap = {
1303
+ userSaved: { id: string }
1304
+ searchChanged: { query: string }
1305
+ }
1306
+
1307
+ type Listener<K extends keyof EventMap> = (payload: EventMap[K]) => void
1308
+ const listeners = new Map<keyof EventMap, Set<Listener<keyof EventMap>>>()
1309
+
1310
+ export const on = <K extends keyof EventMap>(event: K, listener: Listener<K>) => {
1311
+ const set = listeners.get(event) ?? new Set()
1312
+ set.add(listener as Listener<keyof EventMap>)
1313
+ listeners.set(event, set)
1314
+ return () => set.delete(listener as Listener<keyof EventMap>)
1315
+ }
1316
+
1317
+ export const emit = <K extends keyof EventMap>(event: K, payload: EventMap[K]) => {
1318
+ const set = listeners.get(event)
1319
+ if (!set) return
1320
+ for (const listener of set) {
1321
+ listener(payload)
1322
+ }
1323
+ }
1324
+ ```
1325
+
732
1326
  ### Example: Scoped invalidation by key
733
1327
 
734
1328
  When a large list exists, invalidate only the affected rows.
@@ -748,51 +1342,62 @@ export const invalidateUserRow = (id: string) => {
748
1342
  }
749
1343
 
750
1344
  export const UserRow = component<{ id: string; name: string }>((props, handle) => {
751
- handle.effect(() => bindUserRow(props.id, handle))
1345
+ handle.onBeforeMount(() => bindUserRow(props.id, handle))
752
1346
  return () => div(props.name)
753
1347
  })
754
1348
  ```
755
1349
 
756
1350
  ### Explicit batching (optional)
757
1351
 
758
- If you dispatch many invalidations in a single tick, queue them and update once per handle.
1352
+ If you dispatch many invalidations in a single tick, you can wrap them in `batch()` or queue them
1353
+ and update once per handle.
1354
+
1355
+ Useful cases:
1356
+
1357
+ - Multiple store mutations in one click (only one update per handle).
1358
+ - Toggling many rows or cards at once.
1359
+ - Applying filters that update multiple feature roots.
759
1360
 
760
1361
  ```ts
761
- import type { Handle } from '@vanijs/vani'
1362
+ import { batch, type Handle } from '@vanijs/vani'
762
1363
 
763
1364
  const pending = new Set<Handle>()
764
1365
  let scheduled = false
765
1366
 
766
1367
  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()
1368
+ batch(() => {
1369
+ pending.add(handle)
1370
+ if (scheduled) return
1371
+ scheduled = true
1372
+ queueMicrotask(() => {
1373
+ scheduled = false
1374
+ for (const item of pending) item.update()
1375
+ pending.clear()
1376
+ })
774
1377
  })
775
1378
  }
776
1379
  ```
777
1380
 
778
1381
  ### Challenges with manual invalidation at scale
779
1382
 
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.
1383
+ - Update fan-out: a single command may need to notify many modules. Use a coordinator or explicit
1384
+ event channel so the fan-out is visible and testable.
1385
+ - Over-invalidating: calling `handle.update()` on large roots can cause avoidable work. Prefer
1386
+ small, keyed targets (row components, feature roots) and batch per handle.
1387
+ - Under-invalidating: missing a manual update leaves views stale. Make "update after mutation" part
1388
+ of the module contract and centralize mutations behind commands.
1389
+ - Ordering and race conditions: when modules depend on shared data, update data first, then
1390
+ invalidate in a stable order; avoid interleaving async refreshes without a coordinator.
1391
+ - Stale reads during transitions: if you defer with `startTransition()`, ensure that the render
1392
+ reads the latest snapshot or that the transition captures the intended version.
1393
+ - Lifecycle leaks: if a handle isn't unsubscribed, updates keep firing. Always return cleanup from
1394
+ `subscribe()` and bind it through `handle.onBeforeMount()`.
1395
+ - Observability gaps: without implicit reactivity, you need traceability. Wrap invalidation helpers
1396
+ to log or count updates per module and catch runaway loops early.
1397
+
1398
+ Vani trades automatic coordination for transparency. In large apps, invest in clear module
1399
+ boundaries, explicit cross-module APIs, and small invalidation targets to keep manual invalidation
1400
+ manageable.
796
1401
 
797
1402
  ---
798
1403
 
@@ -806,7 +1411,7 @@ Creates a component factory. The `fn` receives `props` and a `handle`.
806
1411
  import { component, div, type Handle } from '@vanijs/vani'
807
1412
 
808
1413
  const Card = component<{ title: string }>((props, handle: Handle) => {
809
- handle.effect(() => {
1414
+ handle.onBeforeMount(() => {
810
1415
  console.log('Mounted:', props.title)
811
1416
  })
812
1417
 
@@ -817,11 +1422,10 @@ const Card = component<{ title: string }>((props, handle: Handle) => {
817
1422
  Components can return other component instances directly:
818
1423
 
819
1424
  ```ts
820
- import { component } from '@vanijs/vani'
821
- import * as h from 'vani/html'
1425
+ import { component, h1 } from '@vanijs/vani'
822
1426
 
823
1427
  const Hero = component(() => {
824
- return () => h.h1('Hello')
1428
+ return () => h1('Hello')
825
1429
  })
826
1430
 
827
1431
  const Page = component(() => {
@@ -831,39 +1435,43 @@ const Page = component(() => {
831
1435
 
832
1436
  ### `renderToDOM(components, root)`
833
1437
 
834
- Mounts components to the DOM immediately.
1438
+ Mounts components and schedules the first render on the next microtask. Accepts a single component
1439
+ or an array of components.
835
1440
 
836
1441
  ```ts
837
1442
  import { renderToDOM, component, div } from '@vanijs/vani'
838
1443
 
839
1444
  const App = component(() => () => div('App'))
840
- renderToDOM([App()], document.getElementById('app')!)
1445
+ renderToDOM(App(), document.getElementById('app')!)
841
1446
  ```
842
1447
 
843
1448
  ### `hydrateToDOM(components, root)`
844
1449
 
845
- Binds handles to existing DOM (SSR/SSG) without rendering. You must call `handle.update()` to
846
- activate.
1450
+ Binds handles to existing DOM (SSR/SSG) without rendering. Accepts a single component or an array of
1451
+ components. You must call `handle.update()` to activate.
847
1452
 
848
1453
  ```ts
849
1454
  import { hydrateToDOM } from '@vanijs/vani'
850
1455
  import { App } from './app'
851
1456
 
852
1457
  const root = document.getElementById('app')!
853
- const handles = hydrateToDOM([App()], root)
1458
+ const handles = hydrateToDOM(App(), root)
854
1459
  handles.forEach((handle) => handle.update())
855
1460
  ```
856
1461
 
1462
+ If hydration fails due to missing anchors or mismatched structure, Vani raises a `HydrationError`
1463
+ and `hydrateToDOM()` logs it. Other errors are rethrown so they surface immediately.
1464
+
857
1465
  ### `renderToString(components)`
858
1466
 
859
- Server‑side render to HTML with anchors. Import from `vani/ssr`.
1467
+ Server‑side render to HTML with anchors. Accepts a single component or an array of components.
1468
+ Import from `@vanijs/vani`.
860
1469
 
861
1470
  ```ts
862
- import { component } from '@vanijs/vani'
863
- import { renderToString } from 'vani/ssr'
1471
+ import { component, renderToString } from '@vanijs/vani'
864
1472
 
865
1473
  const App = component(() => () => 'Hello SSR')
866
- const html = await renderToString([App()])
1474
+ const html = await renderToString(App())
867
1475
  ```
868
1476
 
869
1477
  ### `mount(component, props)`
@@ -901,21 +1509,39 @@ div(span('Label'), input({ type: 'text' }), button({ onclick: () => {} }, 'Submi
901
1509
 
902
1510
  ### SVG icons (Lucide)
903
1511
 
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.
1512
+ Use the Vite SVG plugin at `src/ecosystem/vite-plugin-vani-svg.ts` and import SVGs with `?vani`.
1513
+ This keeps the bundle small by only including the icons you actually import. Invalid SVG strings
1514
+ throw an error so failures stay obvious.
1515
+
1516
+ In your `vite.config.ts`:
1517
+
1518
+ ```ts
1519
+ import vitePluginVaniSvg from './src/ecosystem/vite-plugin-vani-svg'
1520
+
1521
+ export default defineConfig({
1522
+ plugins: [vitePluginVaniSvg()],
1523
+ })
1524
+ ```
906
1525
 
907
1526
  ```ts
1527
+ import GithubIcon from 'lucide-static/icons/github.svg?vani'
908
1528
  import { component } from '@vanijs/vani'
909
- import { renderSvgString } from '@vanijs/vani/svg'
910
- import { Github } from 'lucide-static'
911
1529
 
912
1530
  const GithubLink = component(() => {
913
- return () =>
914
- renderSvgString(Github, {
915
- size: 16,
916
- className: 'h-4 w-4',
917
- attributes: { 'aria-hidden': 'true' },
918
- })
1531
+ return () => GithubIcon({ size: 16, className: 'h-4 w-4', 'aria-hidden': true })
1532
+ })
1533
+ ```
1534
+
1535
+ ### SVGs as components
1536
+
1537
+ Any SVG can be turned into a Vani component with the same `?vani` suffix.
1538
+
1539
+ ```ts
1540
+ import LogoIcon from './logo.svg?vani'
1541
+ import { component } from '@vanijs/vani'
1542
+
1543
+ const HeaderLogo = component(() => {
1544
+ return () => LogoIcon({ className: 'h-8 w-8', 'aria-hidden': true })
919
1545
  })
920
1546
  ```
921
1547
 
@@ -946,21 +1572,22 @@ const Parent = component(() => {
946
1572
  })
947
1573
  ```
948
1574
 
949
- ### Cleanup and effects
1575
+ ### Cleanup and lifecycle
950
1576
 
951
- Effects are explicit and can return a cleanup function.
1577
+ The `onBeforeMount` hook runs once during component setup (before the first render) and can return a
1578
+ cleanup function.
952
1579
 
953
- If you plan to use vani for a SSR/SSG application, you should use effects to run client-only code
954
- such as accessing the window object, accessing the DOM, etc.
1580
+ If you plan to use vani for a SSR/SSG application, you should use `onBeforeMount` to run client-only
1581
+ code such as accessing the window object, accessing the DOM, etc.
955
1582
 
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.
1583
+ The `onBeforeMount` hook is very simple and runs once during component setup (the component function
1584
+ run). It does not re-run on every `handle.update()`; updates only call the render function.
958
1585
 
959
1586
  ```ts
960
1587
  import { component, div } from '@vanijs/vani'
961
1588
 
962
1589
  const Timer = component((_, handle) => {
963
- handle.effect(() => {
1590
+ handle.onBeforeMount(() => {
964
1591
  const id = setInterval(() => console.log('tick'), 1000)
965
1592
  return () => clearInterval(id)
966
1593
  })
@@ -968,6 +1595,89 @@ const Timer = component((_, handle) => {
968
1595
  })
969
1596
  ```
970
1597
 
1598
+ The `onMount` hook runs after the first render, once the component's nodes are in the DOM. It
1599
+ receives a lazy `getNodes()` function and the parent mount point.
1600
+
1601
+ Benefits:
1602
+
1603
+ - No need to set up refs ahead of time just to access nodes after render.
1604
+ - Easy to initialize external, vanilla JS libraries with all rendered nodes in one place.
1605
+ - `getNodes()` is lazy, so you only pay for DOM traversal if you actually need it.
1606
+
1607
+ ```ts
1608
+ import { component, div } from '@vanijs/vani'
1609
+
1610
+ const Measure = component((_, handle) => {
1611
+ handle.onMount((getNodes, parent) => {
1612
+ const nodes = getNodes()
1613
+ const firstElement = nodes.find((node) => node instanceof HTMLElement)
1614
+ if (!firstElement) return
1615
+
1616
+ const rect = (firstElement as HTMLElement).getBoundingClientRect()
1617
+ console.log('Mounted in', parent, 'size', rect.width, rect.height)
1618
+ })
1619
+
1620
+ return () => div('Measured')
1621
+ })
1622
+ ```
1623
+
1624
+ You can also register cleanup functions directly with `handle.onCleanup()`:
1625
+
1626
+ ```ts
1627
+ import { component, div } from '@vanijs/vani'
1628
+
1629
+ const Subscription = component((_, handle) => {
1630
+ const unsubscribe = someStore.subscribe(() => handle.update())
1631
+ handle.onCleanup(unsubscribe)
1632
+
1633
+ return () => div('Subscribed')
1634
+ })
1635
+ ```
1636
+
1637
+ Both patterns are equivalent. Use `handle.onBeforeMount()` when the setup and cleanup are logically
1638
+ grouped, and `handle.onCleanup()` when you need to register cleanup separately from initialization.
1639
+
1640
+ ### Signals and DOM bindings (optional)
1641
+
1642
+ Signals are opt-in fine-grained state. They update only the DOM nodes bound to them.
1643
+
1644
+ ```ts
1645
+ import { component, button, div, signal, text } from '@vanijs/vani'
1646
+
1647
+ const Counter = component(() => {
1648
+ const [count, setCount] = signal(0)
1649
+ return () =>
1650
+ div(
1651
+ text(() => `Count: ${count()}`),
1652
+ button({ onclick: () => setCount((value) => value + 1) }, 'Inc'),
1653
+ )
1654
+ })
1655
+ ```
1656
+
1657
+ - `signal(initial)` returns `[get, set]`.
1658
+ - `derive(fn)` returns a computed getter.
1659
+ - `effect(fn)` re-runs when signals used inside `fn` change.
1660
+ - `text(getter)` binds a text node to a signal.
1661
+ - `attr(el, name, value)` binds an attribute/class to a signal getter or static value.
1662
+
1663
+ ### Partial attribute updates
1664
+
1665
+ When you only need to update attributes (like `className`), you can request an attribute-only
1666
+ refresh:
1667
+
1668
+ ```ts
1669
+ ref.current?.update({ onlyAttributes: true })
1670
+ ```
1671
+
1672
+ This preserves existing event listeners and children and only patches the root element’s attributes.
1673
+ It applies when the component returns a single root element.
1674
+
1675
+ Useful cases:
1676
+
1677
+ - Toggle a selected row class without touching children.
1678
+ - Flip aria/disabled flags on a button.
1679
+ - Update theme/state classes on a card while leaving its subtree intact.
1680
+
971
1681
  ### Transitions
972
1682
 
973
1683
  `startTransition` marks a group of updates as non-urgent, so they are deferred and batched