@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 +709 -92
- package/README.md +28 -262
- package/dist/lib/index.d.mts +100 -834
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/jsx-dev-runtime.d.mts +2 -0
- package/dist/lib/jsx-dev-runtime.mjs +1 -0
- package/dist/lib/jsx-runtime.d.mts +27 -0
- package/dist/lib/jsx-runtime.mjs +1 -0
- package/dist/lib/runtime-Cx9SKHrc.d.mts +190 -0
- package/dist/lib/runtime-X8xzmXJz.mjs +1 -0
- package/package.json +42 -16
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
|
|
8
|
-
DOM subtree delimited by anchors and only update when you
|
|
7
|
+
Vani is **not** a Virtual DOM, not reactive‑by‑default (signals are opt‑in), 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(
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
514
|
+
renderRows()
|
|
195
515
|
}
|
|
196
516
|
|
|
197
517
|
return () =>
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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)
|
|
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
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
968
|
+
2. View adapters (bind handles)
|
|
526
969
|
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
973
|
+
3. Coordinator or message hub
|
|
532
974
|
|
|
533
|
-
For
|
|
975
|
+
For workflows that span multiple modules, add a thin coordinator that:
|
|
534
976
|
|
|
535
|
-
- orchestrates sequences (
|
|
977
|
+
- orchestrates sequences (save → refresh → notify)
|
|
536
978
|
- calls public APIs of each module
|
|
537
|
-
- never
|
|
979
|
+
- never reaches into private state
|
|
980
|
+
- batches invalidations when multiple modules change together
|
|
538
981
|
|
|
539
|
-
|
|
982
|
+
4. Stable, explicit contracts
|
|
540
983
|
|
|
541
|
-
Use interfaces,
|
|
542
|
-
needs another to update, it calls that module
|
|
543
|
-
|
|
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
|
|
1007
|
+
export type UsersFeatureApi = {
|
|
553
1008
|
getUsers: () => User[]
|
|
554
|
-
|
|
1009
|
+
setUsers: (next: User[]) => void
|
|
1010
|
+
refreshUsers: () => Promise<void>
|
|
1011
|
+
subscribe: (listener: () => void) => () => void
|
|
555
1012
|
}
|
|
556
1013
|
|
|
557
|
-
export const
|
|
1014
|
+
export const createUsersFeature = (): { api: UsersFeatureApi; View: Component } => {
|
|
558
1015
|
let users: User[] = []
|
|
1016
|
+
const listeners = new Set<() => void>()
|
|
559
1017
|
|
|
560
|
-
const
|
|
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
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
###
|
|
1066
|
+
### Example: Event channel for cross-feature updates
|
|
598
1067
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|