@vanijs/vani 0.2.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,11 +64,275 @@ 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)
68
+ ```
69
+
70
+ ---
71
+
72
+ ## JSX mode examples
73
+
74
+ Vani is JS-first and transpiler-free, with an optional JSX adapter. JSX mode requires
75
+ `jsxImportSource` to be set to `@vanijs/vani` and a `.tsx` file.
76
+
77
+ TypeScript config example:
78
+
79
+ ```json
80
+ {
81
+ "compilerOptions": {
82
+ "jsx": "react-jsx",
83
+ "jsxImportSource": "@vanijs/vani"
84
+ }
85
+ }
86
+ ```
87
+
88
+ ### 1) JSX counter button
89
+
90
+ ```tsx
91
+ import { component, renderToDOM, type Handle } from '@vanijs/vani'
92
+
93
+ const Counter = component((_, handle: Handle) => {
94
+ let count = 0
95
+ return () => (
96
+ <button
97
+ type="button"
98
+ onclick={() => {
99
+ count += 1
100
+ handle.update()
101
+ }}
102
+ >
103
+ Count: {count}
104
+ </button>
105
+ )
106
+ })
107
+
108
+ renderToDOM(Counter(), document.getElementById('app')!)
109
+ ```
110
+
111
+ ### 2) JSX component inside JS-first components
112
+
113
+ ```tsx
114
+ import { component } from '@vanijs/vani'
115
+ import * as h from '@vanijs/vani/html'
116
+
117
+ const Badge = component<{ label: string }>((props) => {
118
+ return () => <span>{props.label}</span>
119
+ })
120
+
121
+ const Panel = component(() => {
122
+ return () =>
123
+ h.div(
124
+ 'Mixed render:',
125
+ Badge({ label: 'JSX component' }),
126
+ h.span('inside a JS-first component.'),
127
+ )
128
+ })
46
129
  ```
47
130
 
48
131
  ---
49
132
 
133
+ ## Incremental adoption (mounting points)
134
+
135
+ Vani is intentionally small and lightweight, so you don't need to replace your full stack. You can
136
+ adopt it gradually by mounting Vani components inside existing apps (React, Vue, server-rendered
137
+ pages, etc.) using plain DOM elements as mounting points.
138
+
139
+ Benefits:
140
+
141
+ - Gradual migration: move one widget or screen at a time without a rewrite.
142
+ - Minimal surface area: no global runtime or framework lock-in.
143
+ - Clear ownership: a Vani component owns only its subtree between anchors.
144
+ - Easy rollback: remove a mount point and the rest of the app keeps working.
145
+
146
+ ### Example: mount a Vani widget inside React
147
+
148
+ ```tsx
149
+ import { useEffect, useRef } from 'react'
150
+ import { component, div, button, renderToDOM, type Handle } from '@vanijs/vani'
151
+
152
+ // Vani component (local state + explicit updates)
153
+ const VaniCounter = component((_, handle) => {
154
+ let count = 0
155
+ return () =>
156
+ div(
157
+ `Count: ${count}`,
158
+ button(
159
+ {
160
+ onclick: () => {
161
+ count += 1
162
+ handle.update()
163
+ },
164
+ },
165
+ 'Increment',
166
+ ),
167
+ )
168
+ })
169
+
170
+ // React component that hosts the Vani widget
171
+ export function MyReactComponent() {
172
+ const containerRef = useRef<HTMLDivElement>(null)
173
+ const vaniHandlesRef = useRef<Handle[] | null>(null)
174
+
175
+ useEffect(() => {
176
+ if (!containerRef.current) return
177
+
178
+ // Mount Vani into a React-managed DOM element (the mounting point)
179
+ vaniHandlesRef.current = renderToDOM(VaniCounter(), containerRef.current)
180
+
181
+ // Cleanup when React unmounts
182
+ return () => {
183
+ for (const handle of vaniHandlesRef.current ?? []) {
184
+ handle.dispose()
185
+ }
186
+ vaniHandlesRef.current = null
187
+ }
188
+ }, [])
189
+
190
+ return <div ref={containerRef} />
191
+ }
192
+ ```
193
+
194
+ ---
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
+
50
336
  ## Core Concepts
51
337
 
52
338
  ### 1) Components are functions
@@ -102,21 +388,34 @@ Updates replace only the DOM between anchors.
102
388
 
103
389
  ### 4) Lists and item-level updates
104
390
 
105
- Lists are efficient in Vani when each item is its own component. Every item owns a tiny subtree and
106
- can update itself (or be updated via a ref) without touching siblings. Use `key` to preserve
107
- 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.
108
394
 
109
395
  Key ideas:
110
396
 
111
397
  - Represent list data by id (Map or array + id).
112
398
  - Render each row as a keyed component.
113
399
  - Store a `ComponentRef` per id so you can call `ref.current?.update()` for that item only.
114
- - 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.
115
403
 
116
404
  Example:
117
405
 
118
406
  ```ts
119
- 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'
120
419
 
121
420
  type Todo = { id: string; text: string; done: boolean }
122
421
 
@@ -154,6 +453,7 @@ const List = component((_, handle: Handle) => {
154
453
  ])
155
454
 
156
455
  const refs = new Map<string, ComponentRef>()
456
+ const listRef: DomRef<HTMLUListElement> = { current: null }
157
457
  const getRef = (id: string) => {
158
458
  let ref = refs.get(id)
159
459
  if (!ref) {
@@ -180,32 +480,43 @@ const List = component((_, handle: Handle) => {
180
480
 
181
481
  const rename = (id: string, text: string) => updateItem(id, { text })
182
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
+
183
503
  const add = (text: string) => {
184
504
  const id = String(order.length + 1)
185
505
  items.set(id, { id, text, done: false })
186
506
  order = [...order, id]
187
- handle.update()
507
+ renderRows()
188
508
  }
189
509
 
190
510
  const remove = (id: string) => {
191
511
  items.delete(id)
192
512
  refs.delete(id)
193
513
  order = order.filter((value) => value !== id)
194
- handle.update()
514
+ renderRows()
195
515
  }
196
516
 
197
517
  return () =>
198
- ul(
199
- order.map((id) =>
200
- Row({
201
- key: id,
202
- ref: getRef(id),
203
- id,
204
- getItem,
205
- onToggle: toggle,
206
- onRename: rename,
207
- }),
208
- ),
518
+ div(
519
+ ul({ ref: listRef }),
209
520
  button({ onclick: () => add('New item') }, 'Add'),
210
521
  button(
211
522
  {
@@ -221,7 +532,7 @@ const List = component((_, handle: Handle) => {
221
532
  ```
222
533
 
223
534
  This pattern keeps updates local: changing an item triggers only that row’s subtree update, while
224
- structural list changes re-render the list container and reuse keyed rows.
535
+ structural changes are handled by `renderKeyedChildren()` on the list container.
225
536
 
226
537
  ---
227
538
 
@@ -358,7 +669,31 @@ component’s subtree; the conditional element is added or removed accordingly.
358
669
 
359
670
  ---
360
671
 
361
- ### 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
362
697
 
363
698
  In large apps, keep each UI region as its own component root, and schedule updates explicitly. Use
364
699
  microtasks for immediate batching and `startTransition()` for non‑urgent work. This lets you control
@@ -409,6 +744,82 @@ This design keeps scheduling predictable: each region updates at most once per f
409
744
  decide which updates are urgent vs. deferred. If a region needs data from another, call its public
410
745
  API first, then schedule both regions explicitly in the same flush.
411
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
+
412
823
  ---
413
824
 
414
825
  ## Advanced patterns
@@ -514,99 +925,246 @@ export const emit = (event: string, payload?: unknown) => {
514
925
 
515
926
  ## Large-scale app architecture
516
927
 
517
- Vani scales best when you keep update paths explicit and module boundaries clear. The core idea is
518
- to let feature modules own their local state and expose small, explicit APIs for coordination,
519
- instead of reaching into each other’s state or relying on global reactive graphs.
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
+ ```
520
956
 
521
957
  ### Suggested architecture
522
958
 
523
- 1. Feature modules
959
+ 1. Feature modules (state + commands)
960
+
961
+ Each module owns its state and exposes a small API:
962
+
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
524
967
 
525
- Each module exposes:
968
+ 2. View adapters (bind handles)
526
969
 
527
- - a small state container
528
- - read accessors (snapshot getters)
529
- - explicit mutation functions that call `handle.update()` on the owning component(s)
970
+ Views subscribe once via `handle.effect()` and call `handle.update()` when their module notifies.
971
+ This keeps invalidation scoped to the subtree that owns the handle.
530
972
 
531
- 2. Coordinator (optional)
973
+ 3. Coordinator or message hub
532
974
 
533
- For cross-module workflows, add a thin coordinator that:
975
+ For workflows that span multiple modules, add a thin coordinator that:
534
976
 
535
- - orchestrates sequences (e.g. save → refresh → notify)
977
+ - orchestrates sequences (save → refresh → notify)
536
978
  - calls public APIs of each module
537
- - never accesses private state directly
979
+ - never reaches into private state
980
+ - batches invalidations when multiple modules change together
538
981
 
539
- 3. Stable, explicit contracts
982
+ 4. Stable, explicit contracts
540
983
 
541
- Use interfaces, simple message payloads, or callbacks to avoid implicit coupling. If one feature
542
- needs another to update, it calls that modules exported `invalidate()` (or specific mutation
543
- method) rather than mutating shared data.
984
+ Use interfaces, small message payloads, or callbacks to avoid implicit coupling. If one feature
985
+ needs another to update, it calls that module's exported command (or `invalidate()` helper) rather
986
+ than mutating shared data.
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.
544
999
 
545
1000
  ### Example: Feature module with explicit invalidation
546
1001
 
547
1002
  ```ts
548
- import { component, div, type Handle } from '@vanijs/vani'
1003
+ import { component, div, type Handle, type Component } from '@vanijs/vani'
549
1004
 
550
1005
  type User = { id: string; name: string }
551
1006
 
552
- export type UserFeatureApi = {
1007
+ export type UsersFeatureApi = {
553
1008
  getUsers: () => User[]
554
- refreshUsers: () => void
1009
+ setUsers: (next: User[]) => void
1010
+ refreshUsers: () => Promise<void>
1011
+ subscribe: (listener: () => void) => () => void
555
1012
  }
556
1013
 
557
- export const UsersView = component((_, handle: Handle) => {
1014
+ export const createUsersFeature = (): { api: UsersFeatureApi; View: Component } => {
558
1015
  let users: User[] = []
1016
+ const listeners = new Set<() => void>()
559
1017
 
560
- const getUsers = () => users
561
-
562
- const setUsers = (next: User[]) => {
563
- users = next
564
- handle.update()
1018
+ const notify = () => {
1019
+ for (const listener of listeners) listener()
565
1020
  }
566
1021
 
567
- const refreshUsers = async () => {
568
- const response = await fetch('/api/users')
569
- const data = (await response.json()) as User[]
570
- setUsers(data)
1022
+ const api: UsersFeatureApi = {
1023
+ getUsers: () => users,
1024
+ setUsers: (next) => {
1025
+ users = next
1026
+ notify()
1027
+ },
1028
+ refreshUsers: async () => {
1029
+ const response = await fetch('/api/users')
1030
+ const data = (await response.json()) as User[]
1031
+ api.setUsers(data)
1032
+ },
1033
+ subscribe: (listener) => {
1034
+ listeners.add(listener)
1035
+ return () => listeners.delete(listener)
1036
+ },
571
1037
  }
572
1038
 
573
- ;(UsersView as any).api = { getUsers, refreshUsers } satisfies UserFeatureApi
1039
+ const View = component((_, handle: Handle) => {
1040
+ handle.effect(() => api.subscribe(() => handle.update()))
1041
+ return () => div(api.getUsers().map((user) => div(user.name)))
1042
+ })
574
1043
 
575
- return () => div(getUsers().map((user) => div(user.name)))
576
- })
1044
+ return { api, View }
1045
+ }
577
1046
  ```
578
1047
 
579
1048
  ### Example: Coordinator calling explicit APIs
580
1049
 
581
1050
  ```ts
582
- import type { UserFeatureApi } from './users-feature'
1051
+ import type { UsersFeatureApi } from './users-feature'
583
1052
 
584
1053
  type Coordinator = {
585
- onUserSaved: () => void
1054
+ onUserSaved: () => Promise<void>
586
1055
  }
587
1056
 
588
- export const createCoordinator = (users: UserFeatureApi): Coordinator => {
1057
+ export const createCoordinator = (users: UsersFeatureApi): Coordinator => {
589
1058
  return {
590
- onUserSaved: () => {
591
- users.refreshUsers()
1059
+ onUserSaved: async () => {
1060
+ await users.refreshUsers()
592
1061
  },
593
1062
  }
594
1063
  }
595
1064
  ```
596
1065
 
597
- ### Challenges with manual invalidation at scale
1066
+ ### Example: Event channel for cross-feature updates
598
1067
 
599
- - Update fan‑out: one action may need to notify several modules; keep this explicit via a
600
- coordinator instead of hidden subscriptions.
601
- - Over‑invalidating: it’s easy to call `handle.update()` too broadly; prefer small, keyed subtrees
602
- (item components, feature-level roots).
603
- - Stale reads: when multiple modules depend on shared data, ensure you update the data first, then
604
- invalidate dependent modules in a predictable order.
605
- - Debugging update paths: without implicit reactivity, you must track who called `update()`. Keep
606
- module APIs narrow and name update methods clearly (`refreshUsers`, `invalidateSearch`).
1068
+ ```ts
1069
+ type EventMap = {
1070
+ userSaved: { id: string }
1071
+ searchChanged: { query: string }
1072
+ }
607
1073
 
608
- Vani trades automatic coordination for transparency. In large apps, that means you should invest in
609
- clear module boundaries, explicit cross-module APIs, and small invalidation targets.
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
+
1093
+ ### Example: Scoped invalidation by key
1094
+
1095
+ When a large list exists, invalidate only the affected rows.
1096
+
1097
+ ```ts
1098
+ import { component, div, type Handle } from '@vanijs/vani'
1099
+
1100
+ const rowHandles = new Map<string, Handle>()
1101
+
1102
+ export const bindUserRow = (id: string, handle: Handle) => {
1103
+ rowHandles.set(id, handle)
1104
+ return () => rowHandles.delete(id)
1105
+ }
1106
+
1107
+ export const invalidateUserRow = (id: string) => {
1108
+ rowHandles.get(id)?.update()
1109
+ }
1110
+
1111
+ export const UserRow = component<{ id: string; name: string }>((props, handle) => {
1112
+ handle.effect(() => bindUserRow(props.id, handle))
1113
+ return () => div(props.name)
1114
+ })
1115
+ ```
1116
+
1117
+ ### Explicit batching (optional)
1118
+
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.
1127
+
1128
+ ```ts
1129
+ import { batch, type Handle } from '@vanijs/vani'
1130
+
1131
+ const pending = new Set<Handle>()
1132
+ let scheduled = false
1133
+
1134
+ export const queueUpdate = (handle: Handle) => {
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
+ })
1144
+ })
1145
+ }
1146
+ ```
1147
+
1148
+ ### Challenges with manual invalidation at scale
1149
+
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.
610
1168
 
611
1169
  ---
612
1170
 
@@ -632,7 +1190,7 @@ Components can return other component instances directly:
632
1190
 
633
1191
  ```ts
634
1192
  import { component } from '@vanijs/vani'
635
- import * as h from 'vani/html'
1193
+ import * as h from '@vanijs/vani'
636
1194
 
637
1195
  const Hero = component(() => {
638
1196
  return () => h.h1('Hello')
@@ -645,39 +1203,40 @@ const Page = component(() => {
645
1203
 
646
1204
  ### `renderToDOM(components, root)`
647
1205
 
648
- 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.
649
1208
 
650
1209
  ```ts
651
1210
  import { renderToDOM, component, div } from '@vanijs/vani'
652
1211
 
653
1212
  const App = component(() => () => div('App'))
654
- renderToDOM([App()], document.getElementById('app')!)
1213
+ renderToDOM(App(), document.getElementById('app')!)
655
1214
  ```
656
1215
 
657
1216
  ### `hydrateToDOM(components, root)`
658
1217
 
659
- Binds handles to existing DOM (SSR/SSG) without rendering. You must call `handle.update()` to
660
- 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.
661
1220
 
662
1221
  ```ts
663
1222
  import { hydrateToDOM } from '@vanijs/vani'
664
1223
  import { App } from './app'
665
1224
 
666
1225
  const root = document.getElementById('app')!
667
- const handles = hydrateToDOM([App()], root)
1226
+ const handles = hydrateToDOM(App(), root)
668
1227
  handles.forEach((handle) => handle.update())
669
1228
  ```
670
1229
 
671
1230
  ### `renderToString(components)`
672
1231
 
673
- 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`.
674
1234
 
675
1235
  ```ts
676
- import { component } from '@vanijs/vani'
677
- import { renderToString } from 'vani/ssr'
1236
+ import { component, renderToString } from '@vanijs/vani'
678
1237
 
679
1238
  const App = component(() => () => 'Hello SSR')
680
- const html = await renderToString([App()])
1239
+ const html = await renderToString(App())
681
1240
  ```
682
1241
 
683
1242
  ### `mount(component, props)`
@@ -715,21 +1274,38 @@ div(span('Label'), input({ type: 'text' }), button({ onclick: () => {} }, 'Submi
715
1274
 
716
1275
  ### SVG icons (Lucide)
717
1276
 
718
- Vani can render SVG strings directly using `renderSvgString()`. With `lucide-static`, import just
719
- 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`:
720
1281
 
721
1282
  ```ts
1283
+ import vitePluginVaniSvg from './src/ecosystem/vite-plugin-vani-svg'
1284
+
1285
+ export default defineConfig({
1286
+ plugins: [vitePluginVaniSvg()],
1287
+ })
1288
+ ```
1289
+
1290
+ ```ts
1291
+ import GithubIcon from 'lucide-static/icons/github.svg?vani'
722
1292
  import { component } from '@vanijs/vani'
723
- import { renderSvgString } from '@vanijs/vani/svg'
724
- import { Github } from 'lucide-static'
725
1293
 
726
1294
  const GithubLink = component(() => {
727
- return () =>
728
- renderSvgString(Github, {
729
- size: 16,
730
- className: 'h-4 w-4',
731
- attributes: { 'aria-hidden': 'true' },
732
- })
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 })
733
1309
  })
734
1310
  ```
735
1311
 
@@ -782,6 +1358,47 @@ const Timer = component((_, handle) => {
782
1358
  })
783
1359
  ```
784
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
+
785
1402
  ### Transitions
786
1403
 
787
1404
  `startTransition` marks a group of updates as non-urgent, so they are deferred and batched