@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 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,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 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.
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 the list handle only when the list structure changes (add/remove/reorder).
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 { component, ul, li, input, button, type Handle, type ComponentRef } from '@vanijs/vani'
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
- handle.update()
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
- handle.update()
514
+ renderRows()
308
515
  }
309
516
 
310
517
  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
- ),
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 list changes re-render the list container and reuse keyed rows.
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) Scheduling across independent regions
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 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.
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
- - 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
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 (optional)
973
+ 3. Coordinator or message hub
653
974
 
654
- For cross-module workflows, add a thin coordinator that:
975
+ For workflows that span multiple modules, add a thin coordinator that:
655
976
 
656
- - orchestrates sequences (e.g. save → refresh → notify)
977
+ - orchestrates sequences (save → refresh → notify)
657
978
  - calls public APIs of each module
658
- - never accesses private state directly
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, simple message payloads, or callbacks to avoid implicit coupling. If one feature
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, queue them and update once per handle.
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 { Handle } from '@vanijs/vani'
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
- 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()
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: 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.
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/html'
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 to the DOM immediately.
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([App()], document.getElementById('app')!)
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. You must call `handle.update()` to
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([App()], root)
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. Import from `vani/ssr`.
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([App()])
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
- 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.
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
- renderSvgString(Github, {
915
- size: 16,
916
- className: 'h-4 w-4',
917
- attributes: { 'aria-hidden': 'true' },
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