@vanijs/vani 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DOCS.md CHANGED
@@ -47,6 +47,119 @@ renderToDOM([Counter()], appRoot)
47
47
 
48
48
  ---
49
49
 
50
+ ## JSX mode examples
51
+
52
+ Vani is JS-first and transpiler-free, with an optional JSX adapter. JSX mode requires
53
+ `jsxImportSource` to be set to `@vanijs/vani` and a `.tsx` file.
54
+
55
+ ### 1) JSX counter button
56
+
57
+ ```tsx
58
+ import { component, renderToDOM, type Handle } from '@vanijs/vani'
59
+
60
+ const Counter = component((_, handle: Handle) => {
61
+ let count = 0
62
+ return () => (
63
+ <button
64
+ type="button"
65
+ onclick={() => {
66
+ count += 1
67
+ handle.update()
68
+ }}
69
+ >
70
+ Count: {count}
71
+ </button>
72
+ )
73
+ })
74
+
75
+ renderToDOM([Counter()], document.getElementById('app')!)
76
+ ```
77
+
78
+ ### 2) JSX component inside JS-first components
79
+
80
+ ```tsx
81
+ import { component } from '@vanijs/vani'
82
+ import * as h from '@vanijs/vani/html'
83
+
84
+ const Badge = component<{ label: string }>((props) => {
85
+ return () => <span>{props.label}</span>
86
+ })
87
+
88
+ const Panel = component(() => {
89
+ return () =>
90
+ h.div(
91
+ 'Mixed render:',
92
+ Badge({ label: 'JSX component' }),
93
+ h.span('inside a JS-first component.'),
94
+ )
95
+ })
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Incremental adoption (mounting points)
101
+
102
+ Vani is intentionally small and lightweight, so you don't need to replace your full stack. You can
103
+ adopt it gradually by mounting Vani components inside existing apps (React, Vue, server-rendered
104
+ pages, etc.) using plain DOM elements as mounting points.
105
+
106
+ Benefits:
107
+
108
+ - Gradual migration: move one widget or screen at a time without a rewrite.
109
+ - Minimal surface area: no global runtime or framework lock-in.
110
+ - Clear ownership: a Vani component owns only its subtree between anchors.
111
+ - Easy rollback: remove a mount point and the rest of the app keeps working.
112
+
113
+ ### Example: mount a Vani widget inside React
114
+
115
+ ```tsx
116
+ import { useEffect, useRef } from 'react'
117
+ import { component, div, button, renderToDOM, type Handle } from '@vanijs/vani'
118
+
119
+ // Vani component (local state + explicit updates)
120
+ const VaniCounter = component((_, handle) => {
121
+ let count = 0
122
+ return () =>
123
+ div(
124
+ `Count: ${count}`,
125
+ button(
126
+ {
127
+ onclick: () => {
128
+ count += 1
129
+ handle.update()
130
+ },
131
+ },
132
+ 'Increment',
133
+ ),
134
+ )
135
+ })
136
+
137
+ // React component that hosts the Vani widget
138
+ export function MyReactComponent() {
139
+ const containerRef = useRef<HTMLDivElement>(null)
140
+ const vaniHandlesRef = useRef<Handle[] | null>(null)
141
+
142
+ useEffect(() => {
143
+ if (!containerRef.current) return
144
+
145
+ // Mount Vani into a React-managed DOM element (the mounting point)
146
+ vaniHandlesRef.current = renderToDOM([VaniCounter()], containerRef.current)
147
+
148
+ // Cleanup when React unmounts
149
+ return () => {
150
+ for (const handle of vaniHandlesRef.current ?? []) {
151
+ handle.dispose()
152
+ }
153
+ vaniHandlesRef.current = null
154
+ }
155
+ }, [])
156
+
157
+ return <div ref={containerRef} />
158
+ }
159
+ ```
160
+
161
+ ---
162
+
50
163
  ## Core Concepts
51
164
 
52
165
  ### 1) Components are functions
@@ -516,19 +629,27 @@ export const emit = (event: string, payload?: unknown) => {
516
629
 
517
630
  Vani scales best when you keep update paths explicit and module boundaries clear. The core idea is
518
631
  to let feature modules own their local state and expose small, explicit APIs for coordination,
519
- instead of reaching into each others state or relying on global reactive graphs.
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.
520
635
 
521
636
  ### Suggested architecture
522
637
 
523
- 1. Feature modules
638
+ 1. Feature modules (state + commands)
524
639
 
525
640
  Each module exposes:
526
641
 
527
642
  - a small state container
528
643
  - read accessors (snapshot getters)
529
- - explicit mutation functions that call `handle.update()` on the owning component(s)
644
+ - explicit commands (mutations) that notify listeners
645
+ - a `subscribe(listener)` for views to bind invalidation
646
+
647
+ 2. View adapters (bind handles)
648
+
649
+ Views subscribe once via `handle.effect()` and call `handle.update()` when their module notifies.
650
+ This keeps invalidation scoped to the subtree that owns the handle.
530
651
 
531
- 2. Coordinator (optional)
652
+ 3. Coordinator (optional)
532
653
 
533
654
  For cross-module workflows, add a thin coordinator that:
534
655
 
@@ -536,74 +657,139 @@ For cross-module workflows, add a thin coordinator that:
536
657
  - calls public APIs of each module
537
658
  - never accesses private state directly
538
659
 
539
- 3. Stable, explicit contracts
660
+ 4. Stable, explicit contracts
540
661
 
541
662
  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.
663
+ needs another to update, it calls that module's exported command (or `invalidate()` helper) rather
664
+ than mutating shared data.
544
665
 
545
666
  ### Example: Feature module with explicit invalidation
546
667
 
547
668
  ```ts
548
- import { component, div, type Handle } from '@vanijs/vani'
669
+ import { component, div, type Handle, type Component } from '@vanijs/vani'
549
670
 
550
671
  type User = { id: string; name: string }
551
672
 
552
- export type UserFeatureApi = {
673
+ export type UsersFeatureApi = {
553
674
  getUsers: () => User[]
554
- refreshUsers: () => void
675
+ setUsers: (next: User[]) => void
676
+ refreshUsers: () => Promise<void>
677
+ subscribe: (listener: () => void) => () => void
555
678
  }
556
679
 
557
- export const UsersView = component((_, handle: Handle) => {
680
+ export const createUsersFeature = (): { api: UsersFeatureApi; View: Component } => {
558
681
  let users: User[] = []
682
+ const listeners = new Set<() => void>()
559
683
 
560
- const getUsers = () => users
561
-
562
- const setUsers = (next: User[]) => {
563
- users = next
564
- handle.update()
684
+ const notify = () => {
685
+ for (const listener of listeners) listener()
565
686
  }
566
687
 
567
- const refreshUsers = async () => {
568
- const response = await fetch('/api/users')
569
- const data = (await response.json()) as User[]
570
- setUsers(data)
688
+ const api: UsersFeatureApi = {
689
+ getUsers: () => users,
690
+ setUsers: (next) => {
691
+ users = next
692
+ notify()
693
+ },
694
+ refreshUsers: async () => {
695
+ const response = await fetch('/api/users')
696
+ const data = (await response.json()) as User[]
697
+ api.setUsers(data)
698
+ },
699
+ subscribe: (listener) => {
700
+ listeners.add(listener)
701
+ return () => listeners.delete(listener)
702
+ },
571
703
  }
572
704
 
573
- ;(UsersView as any).api = { getUsers, refreshUsers } satisfies UserFeatureApi
705
+ const View = component((_, handle: Handle) => {
706
+ handle.effect(() => api.subscribe(() => handle.update()))
707
+ return () => div(api.getUsers().map((user) => div(user.name)))
708
+ })
574
709
 
575
- return () => div(getUsers().map((user) => div(user.name)))
576
- })
710
+ return { api, View }
711
+ }
577
712
  ```
578
713
 
579
714
  ### Example: Coordinator calling explicit APIs
580
715
 
581
716
  ```ts
582
- import type { UserFeatureApi } from './users-feature'
717
+ import type { UsersFeatureApi } from './users-feature'
583
718
 
584
719
  type Coordinator = {
585
- onUserSaved: () => void
720
+ onUserSaved: () => Promise<void>
586
721
  }
587
722
 
588
- export const createCoordinator = (users: UserFeatureApi): Coordinator => {
723
+ export const createCoordinator = (users: UsersFeatureApi): Coordinator => {
589
724
  return {
590
- onUserSaved: () => {
591
- users.refreshUsers()
725
+ onUserSaved: async () => {
726
+ await users.refreshUsers()
592
727
  },
593
728
  }
594
729
  }
595
730
  ```
596
731
 
732
+ ### Example: Scoped invalidation by key
733
+
734
+ When a large list exists, invalidate only the affected rows.
735
+
736
+ ```ts
737
+ import { component, div, type Handle } from '@vanijs/vani'
738
+
739
+ const rowHandles = new Map<string, Handle>()
740
+
741
+ export const bindUserRow = (id: string, handle: Handle) => {
742
+ rowHandles.set(id, handle)
743
+ return () => rowHandles.delete(id)
744
+ }
745
+
746
+ export const invalidateUserRow = (id: string) => {
747
+ rowHandles.get(id)?.update()
748
+ }
749
+
750
+ export const UserRow = component<{ id: string; name: string }>((props, handle) => {
751
+ handle.effect(() => bindUserRow(props.id, handle))
752
+ return () => div(props.name)
753
+ })
754
+ ```
755
+
756
+ ### Explicit batching (optional)
757
+
758
+ If you dispatch many invalidations in a single tick, queue them and update once per handle.
759
+
760
+ ```ts
761
+ import type { Handle } from '@vanijs/vani'
762
+
763
+ const pending = new Set<Handle>()
764
+ let scheduled = false
765
+
766
+ export const queueUpdate = (handle: Handle) => {
767
+ pending.add(handle)
768
+ if (scheduled) return
769
+ scheduled = true
770
+ queueMicrotask(() => {
771
+ scheduled = false
772
+ for (const item of pending) item.update()
773
+ pending.clear()
774
+ })
775
+ }
776
+ ```
777
+
597
778
  ### Challenges with manual invalidation at scale
598
779
 
599
- - Update fanout: one action may need to notify several modules; keep this explicit via a
780
+ - Update fan-out: one action may need to notify several modules; keep this explicit via a
600
781
  coordinator instead of hidden subscriptions.
601
- - Overinvalidating: 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`).
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).
607
793
 
608
794
  Vani trades automatic coordination for transparency. In large apps, that means you should invest in
609
795
  clear module boundaries, explicit cross-module APIs, and small invalidation targets.
package/README.md CHANGED
@@ -59,7 +59,7 @@ This guarantees:
59
59
 
60
60
  Vani requires:
61
61
 
62
- - no JSX
62
+ - JS-first by default (optional JSX adapter)
63
63
  - no compiler
64
64
  - no build-time transforms
65
65
  - no generated code
@@ -154,6 +154,24 @@ This separates:
154
154
 
155
155
  ---
156
156
 
157
+ ## JSX (optional)
158
+
159
+ Vani is JS-first and transpiler-free, but it also ships a JSX adapter that maps JSX syntax to the
160
+ same runtime behavior (CSR + SSR).
161
+
162
+ TypeScript config example:
163
+
164
+ ```json
165
+ {
166
+ "compilerOptions": {
167
+ "jsx": "react-jsx",
168
+ "jsxImportSource": "@vanijs/vani"
169
+ }
170
+ }
171
+ ```
172
+
173
+ ---
174
+
157
175
  ## SSR (experimental)
158
176
 
159
177
  Vani SSR is explicit and anchor-based. You call `renderToString`, and the output includes the same
@@ -185,11 +203,17 @@ Notes:
185
203
 
186
204
  - ❌ Not a Virtual DOM
187
205
  - ❌ Not reactive-by-default
188
- - ❌ Not JSX-based
206
+ - ❌ Not JSX-mandatory (optional adapter)
189
207
  - ❌ Not compiler-driven
190
208
  - ❌ Not a template language
191
209
  - ❌ Not a framework that guesses intent
192
210
 
211
+ ### Why not Web Components (yet)
212
+
213
+ Vani does not use Web Components today because the developer ergonomics are still rough and SSR
214
+ support is a key goal. We may revisit this if Web Components bring clear benefits without harming
215
+ productivity and cross-browser compatibility.
216
+
193
217
  ---
194
218
 
195
219
  ## Comparison with Popular Frameworks
@@ -206,7 +230,31 @@ Notes:
206
230
  | SSR without heuristics | ✅ | ❌ | ❌ | ❌ | ❌ |
207
231
  | Dependency-free core | ✅ | ❌ | ❌ | ❌ | ❌ |
208
232
 
209
- ⚠️ = partially / indirectly supported
233
+ ⚠️ = partially / indirectly supported / average
234
+
235
+ The strength of Vani is its predictability and simplicity, while other frameworks focus on developer
236
+ productivity and ease of use, handling a lot of complexity behind the scenes automatically.
237
+
238
+ ### Vani's Sweet Spot
239
+
240
+ ✅ Perfect for:
241
+
242
+ - Dashboard widgets
243
+ - Micro-frontends
244
+ - Live-coding in the browser
245
+ - Embeddable components in other frameworks
246
+ - Performance-critical UIs where you need exact control
247
+ - Server-rendered sites
248
+ - Learning UI fundamentals (no magic, direct DOM)
249
+ - Lightweight SPAs or small Multi-Page Applications
250
+
251
+ ❌ Not ideal for:
252
+
253
+ - Large, complex web applications with many interrelated states
254
+ - Teams that want framework conventions to handle complexity
255
+ - Projects needing a mature ecosystem
256
+
257
+ (at least not yet)
210
258
 
211
259
  ---
212
260