@vanijs/vani 0.1.0 → 0.2.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
@@ -12,7 +12,7 @@ DOM subtree delimited by anchors and only update when you explicitly ask them to
12
12
  ## Install
13
13
 
14
14
  ```bash
15
- pnpm add vani
15
+ pnpm add @vanijs/vani
16
16
  ```
17
17
 
18
18
  ---
@@ -63,7 +63,9 @@ const Hello = component(() => {
63
63
 
64
64
  ### 2) Explicit updates
65
65
 
66
- Nothing re‑renders unless you call `handle.update()`:
66
+ Re-renders are always explicit: call `handle.update()` to refresh a component’s subtree. The only
67
+ automatic update is the initial mount (or `clientOnly` during hydration), which schedules the first
68
+ render for you.
67
69
 
68
70
  ```ts
69
71
  import { component, div, button, type Handle } from '@vanijs/vani'
@@ -98,6 +100,514 @@ Each component owns a DOM range delimited by anchors:
98
100
 
99
101
  Updates replace only the DOM between anchors.
100
102
 
103
+ ### 4) Lists and item-level updates
104
+
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.
108
+
109
+ Key ideas:
110
+
111
+ - Represent list data by id (Map or array + id).
112
+ - Render each row as a keyed component.
113
+ - 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).
115
+
116
+ Example:
117
+
118
+ ```ts
119
+ import { component, ul, li, input, button, type Handle, type ComponentRef } from '@vanijs/vani'
120
+
121
+ type Todo = { id: string; text: string; done: boolean }
122
+
123
+ const Row = component<{
124
+ id: string
125
+ getItem: (id: string) => Todo | undefined
126
+ onToggle: (id: string) => void
127
+ onRename: (id: string, text: string) => void
128
+ }>((props) => {
129
+ return () => {
130
+ const item = props.getItem(props.id)
131
+ if (!item) return null
132
+ return li(
133
+ input({
134
+ type: 'checkbox',
135
+ checked: item.done,
136
+ onchange: () => props.onToggle(item.id),
137
+ }),
138
+ input({
139
+ value: item.text,
140
+ oninput: (event) => {
141
+ const value = (event.currentTarget as HTMLInputElement).value
142
+ props.onRename(item.id, value)
143
+ },
144
+ }),
145
+ )
146
+ }
147
+ })
148
+
149
+ const List = component((_, handle: Handle) => {
150
+ let order = ['a', 'b']
151
+ const items = new Map<string, Todo>([
152
+ ['a', { id: 'a', text: 'Ship Vani', done: false }],
153
+ ['b', { id: 'b', text: 'Write docs', done: true }],
154
+ ])
155
+
156
+ const refs = new Map<string, ComponentRef>()
157
+ const getRef = (id: string) => {
158
+ let ref = refs.get(id)
159
+ if (!ref) {
160
+ ref = { current: null }
161
+ refs.set(id, ref)
162
+ }
163
+ return ref
164
+ }
165
+
166
+ const getItem = (id: string) => items.get(id)
167
+
168
+ const updateItem = (id: string, next: Partial<Todo>) => {
169
+ const current = items.get(id)
170
+ if (!current) return
171
+ items.set(id, { ...current, ...next })
172
+ refs.get(id)?.current?.update()
173
+ }
174
+
175
+ const toggle = (id: string) => {
176
+ const current = items.get(id)
177
+ if (!current) return
178
+ updateItem(id, { done: !current.done })
179
+ }
180
+
181
+ const rename = (id: string, text: string) => updateItem(id, { text })
182
+
183
+ const add = (text: string) => {
184
+ const id = String(order.length + 1)
185
+ items.set(id, { id, text, done: false })
186
+ order = [...order, id]
187
+ handle.update()
188
+ }
189
+
190
+ const remove = (id: string) => {
191
+ items.delete(id)
192
+ refs.delete(id)
193
+ order = order.filter((value) => value !== id)
194
+ handle.update()
195
+ }
196
+
197
+ 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
+ ),
209
+ button({ onclick: () => add('New item') }, 'Add'),
210
+ button(
211
+ {
212
+ onclick: () => {
213
+ const first = order[0]
214
+ if (first) remove(first)
215
+ },
216
+ },
217
+ 'Remove first',
218
+ ),
219
+ )
220
+ })
221
+ ```
222
+
223
+ 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.
225
+
226
+ ---
227
+
228
+ ### 5) Forms with explicit submit
229
+
230
+ For forms, you can keep input values in local variables and update the DOM only on submit. This
231
+ matches Vani’s model: read input changes without re-rendering, then call `handle.update()` when the
232
+ user explicitly submits.
233
+
234
+ Example:
235
+
236
+ ```ts
237
+ import { component, form, label, input, button, div, type Handle } from '@vanijs/vani'
238
+
239
+ const ContactForm = component((_, handle: Handle) => {
240
+ let name = ''
241
+ let email = ''
242
+ let submitted = false
243
+
244
+ const onSubmit = (event: SubmitEvent) => {
245
+ event.preventDefault()
246
+ submitted = true
247
+ handle.update()
248
+ }
249
+
250
+ return () =>
251
+ form(
252
+ { onsubmit: onSubmit },
253
+ label('Name'),
254
+ input({
255
+ name: 'name',
256
+ value: name,
257
+ oninput: (event) => {
258
+ name = (event.currentTarget as HTMLInputElement).value
259
+ },
260
+ }),
261
+ label('Email'),
262
+ input({
263
+ name: 'email',
264
+ type: 'email',
265
+ value: email,
266
+ oninput: (event) => {
267
+ email = (event.currentTarget as HTMLInputElement).value
268
+ },
269
+ }),
270
+ button({ type: 'submit' }, 'Send'),
271
+ submitted ? div(`Submitted: ${name} <${email}>`) : null,
272
+ )
273
+ })
274
+ ```
275
+
276
+ The DOM only updates on submit. Input changes mutate local variables but do not trigger a render
277
+ until the user confirms.
278
+
279
+ ---
280
+
281
+ ### 5.1) Inputs and focus
282
+
283
+ Vani replaces a component’s subtree on update. If you re-render on every keystroke, the input node
284
+ is recreated and the browser will drop focus/selection. Prefer uncontrolled inputs and update on
285
+ submit/blur, or split the input into its own component so only a sibling preview re-renders.
286
+
287
+ If you need a controlled input, preserve focus explicitly:
288
+
289
+ ```ts
290
+ import { component, div, input, type DomRef, type Handle } from '@vanijs/vani'
291
+
292
+ const ControlledInput = component((_, handle: Handle) => {
293
+ const ref: DomRef<HTMLInputElement> = { current: null }
294
+ let value = ''
295
+
296
+ const updateWithFocus = () => {
297
+ const prev = ref.current
298
+ const start = prev?.selectionStart ?? null
299
+ const end = prev?.selectionEnd ?? null
300
+
301
+ handle.updateSync()
302
+
303
+ const next = ref.current
304
+ if (next) {
305
+ next.focus()
306
+ if (start != null && end != null) {
307
+ next.setSelectionRange(start, end)
308
+ }
309
+ }
310
+ }
311
+
312
+ return () =>
313
+ div(
314
+ input({
315
+ ref,
316
+ value,
317
+ oninput: (event) => {
318
+ value = (event.currentTarget as HTMLInputElement).value
319
+ updateWithFocus()
320
+ },
321
+ }),
322
+ div(`Value: ${value}`),
323
+ )
324
+ })
325
+ ```
326
+
327
+ ---
328
+
329
+ ### 6) Conditional rendering
330
+
331
+ Conditional rendering is just normal control flow inside the render function. You compute a boolean
332
+ from your local state and return either the element or `null`. Updates are still explicit: call
333
+ `handle.update()` when you want the condition to be re-evaluated and the DOM to change.
334
+
335
+ Example:
336
+
337
+ ```ts
338
+ import { component, div, button, type Handle } from '@vanijs/vani'
339
+
340
+ const TogglePanel = component((_, handle: Handle) => {
341
+ let open = false
342
+
343
+ const toggle = () => {
344
+ open = !open
345
+ handle.update()
346
+ }
347
+
348
+ return () =>
349
+ div(
350
+ button({ onclick: toggle }, open ? 'Hide details' : 'Show details'),
351
+ open ? div('Now you see me') : null,
352
+ )
353
+ })
354
+ ```
355
+
356
+ The `open` flag is local state. When it changes, you call `handle.update()` to re-render the
357
+ component’s subtree; the conditional element is added or removed accordingly.
358
+
359
+ ---
360
+
361
+ ### 7) Scheduling across independent regions
362
+
363
+ In large apps, keep each UI region as its own component root, and schedule updates explicitly. Use
364
+ microtasks for immediate batching and `startTransition()` for non‑urgent work. This lets you control
365
+ _when_ updates happen without hidden dependencies.
366
+
367
+ Strategy:
368
+
369
+ - Give each region its own `handle`.
370
+ - Coalesce multiple changes in the same tick into a single update per region.
371
+ - Use microtasks for urgent updates (input, selection).
372
+ - Use `startTransition()` for expensive or non‑urgent work (filters, reorders).
373
+ - Avoid cascading updates by keeping regions independent and coordinating through explicit APIs.
374
+
375
+ Example scheduler:
376
+
377
+ ```ts
378
+ import { startTransition, type Handle } from '@vanijs/vani'
379
+
380
+ type RegionId = 'sidebar' | 'content' | 'status'
381
+
382
+ const pending = new Set<RegionId>()
383
+ const handles = new Map<RegionId, Handle>()
384
+
385
+ export const registerRegion = (id: RegionId, handle: Handle) => {
386
+ handles.set(id, handle)
387
+ }
388
+
389
+ export const scheduleRegionUpdate = (id: RegionId, opts?: { transition?: boolean }) => {
390
+ pending.add(id)
391
+
392
+ if (opts?.transition) {
393
+ startTransition(flush)
394
+ return
395
+ }
396
+
397
+ queueMicrotask(flush)
398
+ }
399
+
400
+ const flush = () => {
401
+ for (const id of pending) {
402
+ handles.get(id)?.update()
403
+ }
404
+ pending.clear()
405
+ }
406
+ ```
407
+
408
+ This design keeps scheduling predictable: each region updates at most once per flush, and you can
409
+ decide which updates are urgent vs. deferred. If a region needs data from another, call its public
410
+ API first, then schedule both regions explicitly in the same flush.
411
+
412
+ ---
413
+
414
+ ## Advanced patterns
415
+
416
+ These patterns stay explicit while scaling across larger apps.
417
+
418
+ ### Global state with subscriptions
419
+
420
+ Use a small store with `getState`, `setState`, and `subscribe`. Components subscribe once and call
421
+ `handle.update()` on changes.
422
+
423
+ ```ts
424
+ // store.ts
425
+ type Listener = () => void
426
+ type AppState = { count: number }
427
+
428
+ let state: AppState = { count: 0 }
429
+ const listeners = new Set<Listener>()
430
+
431
+ export const getState = () => state
432
+ export const setState = (next: AppState) => {
433
+ state = next
434
+ for (const listener of listeners) listener()
435
+ }
436
+ export const subscribe = (listener: Listener) => {
437
+ listeners.add(listener)
438
+ return () => listeners.delete(listener)
439
+ }
440
+ ```
441
+
442
+ ```ts
443
+ import { component, div, button, type Handle } from '@vanijs/vani'
444
+ import { getState, setState, subscribe } from './store'
445
+
446
+ const Counter = component((_, handle: Handle) => {
447
+ handle.effect(() => subscribe(() => handle.update()))
448
+
449
+ return () => {
450
+ const { count } = getState()
451
+ return div(`Count: ${count}`, button({ onclick: () => setState({ count: count + 1 }) }, 'Inc'))
452
+ }
453
+ })
454
+ ```
455
+
456
+ ### Data fetching + cache invalidation
457
+
458
+ Keep a simple cache and explicit invalidation. Updates are manual and predictable.
459
+
460
+ ```ts
461
+ type Listener = () => void
462
+ const listeners = new Set<Listener>()
463
+ const cache = new Map<string, unknown>()
464
+
465
+ export const subscribe = (listener: Listener) => {
466
+ listeners.add(listener)
467
+ return () => listeners.delete(listener)
468
+ }
469
+
470
+ export const getCached = <T>(key: string) => cache.get(key) as T | undefined
471
+
472
+ export const refresh = async (key: string, fetcher: () => Promise<unknown>) => {
473
+ cache.set(key, await fetcher())
474
+ for (const listener of listeners) listener()
475
+ }
476
+ ```
477
+
478
+ ### Derived (selector) state
479
+
480
+ Compute derived values during render, or cache them when the base state changes. This keeps updates
481
+ explicit and avoids hidden dependencies.
482
+
483
+ ```ts
484
+ const getVisibleItems = (items: string[], filter: string) =>
485
+ filter ? items.filter((item) => item.includes(filter)) : items
486
+
487
+ // In render:
488
+ const visible = getVisibleItems(items, filter)
489
+ ```
490
+
491
+ ### Event bus for cross-feature coordination
492
+
493
+ For decoupled features, use a tiny event bus and update explicitly when events fire.
494
+
495
+ ```ts
496
+ type Listener = (payload?: unknown) => void
497
+ const listeners = new Map<string, Set<Listener>>()
498
+
499
+ export const on = (event: string, listener: Listener) => {
500
+ const set = listeners.get(event) ?? new Set<Listener>()
501
+ set.add(listener)
502
+ listeners.set(event, set)
503
+ return () => set.delete(listener)
504
+ }
505
+
506
+ export const emit = (event: string, payload?: unknown) => {
507
+ const set = listeners.get(event)
508
+ if (!set) return
509
+ for (const listener of set) listener(payload)
510
+ }
511
+ ```
512
+
513
+ ---
514
+
515
+ ## Large-scale app architecture
516
+
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.
520
+
521
+ ### Suggested architecture
522
+
523
+ 1. Feature modules
524
+
525
+ Each module exposes:
526
+
527
+ - a small state container
528
+ - read accessors (snapshot getters)
529
+ - explicit mutation functions that call `handle.update()` on the owning component(s)
530
+
531
+ 2. Coordinator (optional)
532
+
533
+ For cross-module workflows, add a thin coordinator that:
534
+
535
+ - orchestrates sequences (e.g. save → refresh → notify)
536
+ - calls public APIs of each module
537
+ - never accesses private state directly
538
+
539
+ 3. Stable, explicit contracts
540
+
541
+ Use interfaces, simple message payloads, or callbacks to avoid implicit coupling. If one feature
542
+ needs another to update, it calls that module’s exported `invalidate()` (or specific mutation
543
+ method) rather than mutating shared data.
544
+
545
+ ### Example: Feature module with explicit invalidation
546
+
547
+ ```ts
548
+ import { component, div, type Handle } from '@vanijs/vani'
549
+
550
+ type User = { id: string; name: string }
551
+
552
+ export type UserFeatureApi = {
553
+ getUsers: () => User[]
554
+ refreshUsers: () => void
555
+ }
556
+
557
+ export const UsersView = component((_, handle: Handle) => {
558
+ let users: User[] = []
559
+
560
+ const getUsers = () => users
561
+
562
+ const setUsers = (next: User[]) => {
563
+ users = next
564
+ handle.update()
565
+ }
566
+
567
+ const refreshUsers = async () => {
568
+ const response = await fetch('/api/users')
569
+ const data = (await response.json()) as User[]
570
+ setUsers(data)
571
+ }
572
+
573
+ ;(UsersView as any).api = { getUsers, refreshUsers } satisfies UserFeatureApi
574
+
575
+ return () => div(getUsers().map((user) => div(user.name)))
576
+ })
577
+ ```
578
+
579
+ ### Example: Coordinator calling explicit APIs
580
+
581
+ ```ts
582
+ import type { UserFeatureApi } from './users-feature'
583
+
584
+ type Coordinator = {
585
+ onUserSaved: () => void
586
+ }
587
+
588
+ export const createCoordinator = (users: UserFeatureApi): Coordinator => {
589
+ return {
590
+ onUserSaved: () => {
591
+ users.refreshUsers()
592
+ },
593
+ }
594
+ }
595
+ ```
596
+
597
+ ### Challenges with manual invalidation at scale
598
+
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`).
607
+
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.
610
+
101
611
  ---
102
612
 
103
613
  ## API Reference (with examples)
@@ -203,6 +713,26 @@ import { div, span, button, input } from '@vanijs/vani'
203
713
  div(span('Label'), input({ type: 'text' }), button({ onclick: () => {} }, 'Submit'))
204
714
  ```
205
715
 
716
+ ### SVG icons (Lucide)
717
+
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.
720
+
721
+ ```ts
722
+ import { component } from '@vanijs/vani'
723
+ import { renderSvgString } from '@vanijs/vani/svg'
724
+ import { Github } from 'lucide-static'
725
+
726
+ const GithubLink = component(() => {
727
+ return () =>
728
+ renderSvgString(Github, {
729
+ size: 16,
730
+ className: 'h-4 w-4',
731
+ attributes: { 'aria-hidden': 'true' },
732
+ })
733
+ })
734
+ ```
735
+
206
736
  ### `classNames(...classes)`
207
737
 
208
738
  Utility for composing class names:
@@ -211,7 +741,7 @@ Utility for composing class names:
211
741
  import { classNames, div } from '@vanijs/vani'
212
742
 
213
743
  div({
214
- className: classNames('base', { active: true }, ['p-2', 'rounded']),
744
+ className: classNames('base', { active: true }, ['p-2', 'rounded-xl']),
215
745
  })
216
746
  ```
217
747
 
@@ -237,7 +767,8 @@ Effects are explicit and can return a cleanup function.
237
767
  If you plan to use vani for a SSR/SSG application, you should use effects to run client-only code
238
768
  such as accessing the window object, accessing the DOM, etc.
239
769
 
240
- Effects are very simple, they don't have dependencies and are run once on mount and once on update.
770
+ Effects are very simple and run once during component setup (the component function run). They do
771
+ not re-run on every `handle.update()`; updates only call the render function.
241
772
 
242
773
  ```ts
243
774
  import { component, div } from '@vanijs/vani'
@@ -303,7 +834,8 @@ const App = component(
303
834
  )
304
835
  ```
305
836
 
306
- They are awaited and the fallback is rendered until the component is ready.
837
+ In DOM mode, the fallback is rendered until the component is ready. In SSR mode, async components
838
+ are awaited, so the fallback only renders for `clientOnly` components.
307
839
 
308
840
  ---
309
841
 
@@ -342,8 +874,47 @@ Use `renderToString()` on the server, then `hydrateToDOM()` on the client.
342
874
 
343
875
  Use `renderToString()` at build time to generate a static `index.html`, then hydrate on the client.
344
876
 
345
- **Important:** Hydration only binds to anchors. It does not render or start effects. Call
346
- `handle.update()` to activate the UI.
877
+ **Important:** Hydration only binds to anchors for normal components. It does not render or start
878
+ effects until you call `handle.update()` to activate the UI. Components marked `clientOnly: true` do
879
+ render on the client during hydration.
880
+
881
+ ---
882
+
883
+ ## Selective Hydration
884
+
885
+ You can hydrate a full page but only **activate** the parts that need interactivity. Since
886
+ `hydrateToDOM()` returns handles, you choose which ones to `update()`.
887
+
888
+ Example: hydrate everything, activate only the header.
889
+
890
+ ```ts
891
+ import { hydrateToDOM, type ComponentRef } from '@vanijs/vani'
892
+ import { Header } from './header'
893
+ import { Main } from './main'
894
+ import { Footer } from './footer'
895
+
896
+ const headerRef: ComponentRef = { current: null }
897
+ const root = document.getElementById('app')!
898
+
899
+ // Must match server render order.
900
+ hydrateToDOM([Header({ ref: headerRef }), Main(), Footer()], root)
901
+
902
+ // Activate only the header.
903
+ headerRef.current?.update()
904
+ ```
905
+
906
+ Alternative: split the page into separate roots and hydrate only the interactive region.
907
+
908
+ ```ts
909
+ const headerRoot = document.getElementById('header-root')!
910
+ const [headerHandle] = hydrateToDOM([Header()], headerRoot)
911
+ headerHandle.update()
912
+ ```
913
+
914
+ Notes:
915
+
916
+ - Non‑updated components remain inert (no handlers/effects) until you call `update()`.
917
+ - The hydration list must match the server render order for that root.
347
918
 
348
919
  ---
349
920
 
@@ -368,6 +939,24 @@ This avoids slow DOM diffing and keeps behavior explicit.
368
939
 
369
940
  ---
370
941
 
942
+ ## Other Resources
943
+
944
+ ### Configuring Tailwind CSS Intellisense (VSCode)
945
+
946
+ In order to have proper Tailwind CSS Intellisense code completion and hover documentation with Vani,
947
+ you need to configure the following settings in your `.vscode/settings.json` file:
948
+
949
+ ```json
950
+ {
951
+ "tailwindCSS.experimental.classRegex": [
952
+ ["(?:tw|clsx|cn)\\(([^;]*)[\\);]", "[`'\"`]([^'\"`;]*)[`'\"`]"],
953
+ "(?:className)=\\s*(?:\"|'|{`)([^(?:\"|'|`})]*)",
954
+ "(?:className):\\s*(?:\"|'|{`)([^(?:\"|'|`})]*)"
955
+ ],
956
+ "tailwindCSS.classAttributes": ["class", "classes", "className", "classNames"]
957
+ }
958
+ ```
959
+
371
960
  ## License
372
961
 
373
962
  MIT