@deijose/nix-js 0.1.1 → 0.1.3
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/README.github.md +1033 -0
- package/README.md +58 -955
- package/README.npm.md +136 -0
- package/dist/lib/nix-js.cjs +5 -6
- package/dist/lib/nix-js.js +8 -751
- package/package.json +11 -14
package/README.github.md
ADDED
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
# ❄️ Nix.js
|
|
2
|
+
|
|
3
|
+
> A lightweight, fully reactive micro-framework for building modern web UIs — no virtual DOM, no compiler, no build-time magic. Just signals, tagged templates, and pure TypeScript.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
~28 KB source · zero dependencies · TypeScript-first · ES2022
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
- [Overview](#overview)
|
|
14
|
+
- [Installation & Setup](#installation--setup)
|
|
15
|
+
- [Core Concepts](#core-concepts)
|
|
16
|
+
- [Reactivity](#reactivity)
|
|
17
|
+
- [signal](#signal)
|
|
18
|
+
- [computed](#computed)
|
|
19
|
+
- [effect](#effect)
|
|
20
|
+
- [batch](#batch)
|
|
21
|
+
- [watch](#watch)
|
|
22
|
+
- [untrack](#untrack)
|
|
23
|
+
- [nextTick](#nexttick)
|
|
24
|
+
- [Templates](#templates)
|
|
25
|
+
- [html tag](#html-tag)
|
|
26
|
+
- [Text bindings](#text-bindings)
|
|
27
|
+
- [Attribute bindings](#attribute-bindings)
|
|
28
|
+
- [Event bindings & modifiers](#event-bindings--modifiers)
|
|
29
|
+
- [Conditional rendering](#conditional-rendering)
|
|
30
|
+
- [List rendering](#list-rendering)
|
|
31
|
+
- [Keyed lists: repeat()](#keyed-lists-repeat)
|
|
32
|
+
- [DOM refs: ref()](#dom-refs-ref)
|
|
33
|
+
- [Components](#components)
|
|
34
|
+
- [Function components](#function-components)
|
|
35
|
+
- [Class components: NixComponent](#class-components-nixcomponent)
|
|
36
|
+
- [Lifecycle hooks](#lifecycle-hooks)
|
|
37
|
+
- [mount()](#mount)
|
|
38
|
+
- [Dependency Injection](#dependency-injection)
|
|
39
|
+
- [provide / inject](#provide--inject)
|
|
40
|
+
- [createInjectionKey](#createinjectionkey)
|
|
41
|
+
- [Global Stores](#global-stores)
|
|
42
|
+
- [createStore](#createstore)
|
|
43
|
+
- [Router](#router)
|
|
44
|
+
- [createRouter](#createrouter)
|
|
45
|
+
- [RouterView](#routerview)
|
|
46
|
+
- [Link](#link)
|
|
47
|
+
- [useRouter](#userouter)
|
|
48
|
+
- [Nested routes](#nested-routes)
|
|
49
|
+
- [Query parameters](#query-parameters)
|
|
50
|
+
- [Async & Lazy Loading](#async--lazy-loading)
|
|
51
|
+
- [suspend()](#suspend)
|
|
52
|
+
- [lazy()](#lazy)
|
|
53
|
+
- [API Reference](#api-reference)
|
|
54
|
+
- [Known Limitations](#known-limitations)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Overview
|
|
59
|
+
|
|
60
|
+
Nix.js is a signal-based reactive micro-framework. Its design goals are:
|
|
61
|
+
|
|
62
|
+
- **No virtual DOM.** Bindings update individual DOM nodes directly via `effect()`.
|
|
63
|
+
- **No compiler.** Templates are standard JavaScript tagged template literals.
|
|
64
|
+
- **Fine-grained reactivity.** Only the exact text nodes and attributes that depend on a changed signal are updated — no diffing of full component trees.
|
|
65
|
+
- **Zero runtime dependencies.** The entire framework is ~28 KB of TypeScript source with no `node_modules` at runtime.
|
|
66
|
+
- **TypeScript-first.** Every public API is fully typed, including typed injection keys and typed store signals.
|
|
67
|
+
|
|
68
|
+
### Architecture at a glance
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
signal() ──── effect() ──────────────────────────────────┐
|
|
72
|
+
│ │
|
|
73
|
+
html`` │
|
|
74
|
+
│ ┌─ text node │
|
|
75
|
+
└── binding ────┤─ attribute (reactive) ─┘
|
|
76
|
+
└─ child node
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Each interpolation inside `html`` creates at most one `effect()`. When a signal changes, only the DOM nodes bound to that signal are updated.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Installation & Setup
|
|
84
|
+
|
|
85
|
+
Nix.js uses [Vite](https://vitejs.dev/) as its dev server and bundler.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Install as a dependency
|
|
89
|
+
npm install @deijose/nix-js
|
|
90
|
+
# or
|
|
91
|
+
bun add @deijose/nix-js
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { signal, html, NixComponent, mount } from "@deijose/nix-js";
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Development (from source)
|
|
99
|
+
|
|
100
|
+
# Start development server
|
|
101
|
+
npm run dev # or: bun dev
|
|
102
|
+
|
|
103
|
+
# Type check
|
|
104
|
+
npx tsc --noEmit
|
|
105
|
+
|
|
106
|
+
# Production build
|
|
107
|
+
npm run build
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Project structure
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
src/
|
|
114
|
+
nix/
|
|
115
|
+
reactivity.ts — signal, effect, computed, batch, watch, untrack, nextTick
|
|
116
|
+
template.ts — html``, repeat(), ref()
|
|
117
|
+
lifecycle.ts — NixComponent base class
|
|
118
|
+
component.ts — mount()
|
|
119
|
+
store.ts — createStore()
|
|
120
|
+
router.ts — createRouter(), RouterView, Link, useRouter()
|
|
121
|
+
async.ts — suspend(), lazy()
|
|
122
|
+
context.ts — provide(), inject(), createInjectionKey()
|
|
123
|
+
index.ts — re-exports everything
|
|
124
|
+
main.ts — application entry point
|
|
125
|
+
index.html
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Import everything from the single entry point:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import {
|
|
132
|
+
signal, computed, effect, batch, watch, untrack, nextTick,
|
|
133
|
+
html, repeat, ref,
|
|
134
|
+
NixComponent, mount,
|
|
135
|
+
createStore,
|
|
136
|
+
createRouter, RouterView, Link, useRouter,
|
|
137
|
+
suspend, lazy,
|
|
138
|
+
provide, inject, createInjectionKey,
|
|
139
|
+
} from "./nix";
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Core Concepts
|
|
145
|
+
|
|
146
|
+
Nix.js is built around three primitives:
|
|
147
|
+
|
|
148
|
+
| Primitive | Role |
|
|
149
|
+
|-----------|------|
|
|
150
|
+
| `signal(v)` | A reactive value. Reading it inside an `effect` creates a subscription. |
|
|
151
|
+
| `effect(fn)` | A function that re-runs whenever any signal it read changes. |
|
|
152
|
+
| `html\`\`` | A tagged template that turns an HTML string + bindings into a live DOM fragment. |
|
|
153
|
+
|
|
154
|
+
Everything else — `computed`, `watch`, `repeat`, `NixComponent`, `createStore`, the router, `provide`/`inject` — is built on top of these three primitives.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Reactivity
|
|
159
|
+
|
|
160
|
+
### `signal`
|
|
161
|
+
|
|
162
|
+
Creates a reactive container for a single value.
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
const count = signal(0);
|
|
166
|
+
|
|
167
|
+
count.value; // get — 0
|
|
168
|
+
count.value = 1; // set — notifies subscribers
|
|
169
|
+
count.update(n => n + 1); // set via updater function
|
|
170
|
+
count.peek(); // get WITHOUT subscribing (no tracking)
|
|
171
|
+
count.dispose(); // remove all subscribers
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Signals use `Object.is` equality — setting the same value does nothing.
|
|
175
|
+
|
|
176
|
+
### `computed`
|
|
177
|
+
|
|
178
|
+
A derived signal whose value is recalculated automatically when its dependencies change.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
const price = signal(10);
|
|
182
|
+
const qty = signal(3);
|
|
183
|
+
const total = computed(() => price.value * qty.value);
|
|
184
|
+
|
|
185
|
+
console.log(total.value); // 30
|
|
186
|
+
|
|
187
|
+
price.value = 20;
|
|
188
|
+
console.log(total.value); // 60 — updated automatically
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`computed` returns a `Signal<T>`, so it has `.value`, `.peek()`, etc.
|
|
192
|
+
|
|
193
|
+
### `effect`
|
|
194
|
+
|
|
195
|
+
Runs a function immediately and re-runs it whenever any signal read inside it changes. Returns a `dispose` function to stop the effect.
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
const name = signal("Alice");
|
|
199
|
+
|
|
200
|
+
const dispose = effect(() => {
|
|
201
|
+
document.title = `Hello, ${name.value}`;
|
|
202
|
+
// optional — return a cleanup function:
|
|
203
|
+
return () => console.log("effect cleaned up");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
name.value = "Bob"; // re-runs the effect → document.title = "Hello, Bob"
|
|
207
|
+
|
|
208
|
+
dispose(); // stops the effect
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Effects are **self-cleaning**: before each re-run, the previous cleanup (if any) is called and all old subscriptions are dropped. This prevents stale subscriptions to signals that are no longer read.
|
|
212
|
+
|
|
213
|
+
### `batch`
|
|
214
|
+
|
|
215
|
+
Groups multiple signal writes into a single effect flush. Without `batch`, each write triggers its effects individually.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
const x = signal(0);
|
|
219
|
+
const y = signal(0);
|
|
220
|
+
|
|
221
|
+
effect(() => console.log(x.value + y.value));
|
|
222
|
+
|
|
223
|
+
// Without batch: effect runs twice
|
|
224
|
+
x.value = 1;
|
|
225
|
+
y.value = 2;
|
|
226
|
+
|
|
227
|
+
// With batch: effect runs once, at the end
|
|
228
|
+
batch(() => {
|
|
229
|
+
x.value = 10;
|
|
230
|
+
y.value = 20;
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### `watch`
|
|
235
|
+
|
|
236
|
+
Watches a reactive source and calls a callback with `(newValue, oldValue)` when it changes. Unlike `effect`, it does **not** run on initialization by default.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
const count = signal(0);
|
|
240
|
+
|
|
241
|
+
const stop = watch(count, (newVal, oldVal) => {
|
|
242
|
+
console.log(`${oldVal} → ${newVal}`);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
count.value = 1; // logs: "0 → 1"
|
|
246
|
+
|
|
247
|
+
stop(); // stop watching
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Options:**
|
|
251
|
+
|
|
252
|
+
| Option | Type | Default | Description |
|
|
253
|
+
|--------|------|---------|-------------|
|
|
254
|
+
| `immediate` | `boolean` | `false` | Run callback immediately with the current value |
|
|
255
|
+
| `once` | `boolean` | `false` | Auto-dispose after the first callback invocation |
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
// Watch a computed expression
|
|
259
|
+
watch(
|
|
260
|
+
() => user.value.role,
|
|
261
|
+
(role) => console.log("Role changed:", role),
|
|
262
|
+
{ immediate: true }
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// One-shot watcher
|
|
266
|
+
watch(
|
|
267
|
+
isReady,
|
|
268
|
+
() => initApp(),
|
|
269
|
+
{ once: true }
|
|
270
|
+
);
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### `untrack`
|
|
274
|
+
|
|
275
|
+
Reads signals inside `fn` without creating subscriptions. Useful when you need a value but don't want the current `effect` to re-run when that signal changes.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const a = signal(1);
|
|
279
|
+
const b = signal(2);
|
|
280
|
+
|
|
281
|
+
effect(() => {
|
|
282
|
+
const aVal = a.value; // subscribed — effect re-runs when a changes
|
|
283
|
+
const bVal = untrack(() => b.value); // NOT subscribed — b changes won't trigger this
|
|
284
|
+
console.log(aVal + bVal);
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### `nextTick`
|
|
289
|
+
|
|
290
|
+
Returns a `Promise<void>` that resolves after the current synchronous effect queue has flushed. Use it to read the DOM after a reactive change.
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
const text = signal("hello");
|
|
294
|
+
|
|
295
|
+
text.value = "world";
|
|
296
|
+
await nextTick();
|
|
297
|
+
console.log(document.querySelector("#el")?.textContent); // "world"
|
|
298
|
+
|
|
299
|
+
// Callback variant:
|
|
300
|
+
await nextTick(() => inputRef.el?.focus());
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Templates
|
|
306
|
+
|
|
307
|
+
### `html` tag
|
|
308
|
+
|
|
309
|
+
`html` is a tagged template literal that returns a `NixTemplate`. It parses the HTML once and creates a `DocumentFragment` with live bindings.
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
import { html, signal, mount } from "./nix";
|
|
313
|
+
|
|
314
|
+
const name = signal("world");
|
|
315
|
+
const tpl = html`<h1>Hello, ${() => name.value}!</h1>`;
|
|
316
|
+
|
|
317
|
+
mount(tpl, "#app");
|
|
318
|
+
name.value = "Nix"; // DOM updates automatically
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Text bindings
|
|
322
|
+
|
|
323
|
+
| Syntax | Behavior |
|
|
324
|
+
|--------|----------|
|
|
325
|
+
| `${value}` | Static — inserted once as a text node |
|
|
326
|
+
| `${() => expr}` | Reactive — updates the text node whenever signals inside change |
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
const count = signal(0);
|
|
330
|
+
|
|
331
|
+
html`
|
|
332
|
+
<p>Static: ${"hello"}</p>
|
|
333
|
+
<p>Reactive: ${() => count.value}</p>
|
|
334
|
+
<p>Expression: ${() => count.value > 0 ? "positive" : "zero or negative"}</p>
|
|
335
|
+
`
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Attribute bindings
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
const active = signal(true);
|
|
342
|
+
const label = signal("Submit");
|
|
343
|
+
const classes = signal("btn btn-primary");
|
|
344
|
+
|
|
345
|
+
html`
|
|
346
|
+
<button
|
|
347
|
+
class=${classes}
|
|
348
|
+
disabled=${() => !active.value}
|
|
349
|
+
aria-label=${() => label.value}
|
|
350
|
+
>Submit</button>
|
|
351
|
+
`
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
- Static value → set once.
|
|
355
|
+
- `() => value` → reactive, updates via `effect`.
|
|
356
|
+
- `null`, `undefined`, or `false` → attribute is **removed**.
|
|
357
|
+
|
|
358
|
+
> **Important:** Each attribute binding must be a single interpolation that covers the entire value. Partial interpolation inside a string is not supported:
|
|
359
|
+
>
|
|
360
|
+
> ```typescript
|
|
361
|
+
> // ✅ Correct — the whole value is one interpolation
|
|
362
|
+
> html`<div class=${() => `item ${active.value ? "active" : ""}`}>`
|
|
363
|
+
>
|
|
364
|
+
> // ❌ Incorrect — mixing a literal prefix with an interpolation
|
|
365
|
+
> html`<div class="item ${() => active.value ? 'active' : ''}">`
|
|
366
|
+
> ```
|
|
367
|
+
|
|
368
|
+
### Event bindings & modifiers
|
|
369
|
+
|
|
370
|
+
Events are bound with `@eventname=`:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
const count = signal(0);
|
|
374
|
+
|
|
375
|
+
html`
|
|
376
|
+
<button @click=${() => count.value++}>Increment</button>
|
|
377
|
+
<input @input=${(e: Event) => console.log((e.target as HTMLInputElement).value)} />
|
|
378
|
+
`
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**Modifiers** are chained after the event name with `.`:
|
|
382
|
+
|
|
383
|
+
| Modifier | Effect |
|
|
384
|
+
|----------|--------|
|
|
385
|
+
| `.prevent` | `e.preventDefault()` |
|
|
386
|
+
| `.stop` | `e.stopPropagation()` |
|
|
387
|
+
| `.once` | Listener removed after first call |
|
|
388
|
+
| `.capture` | `useCapture = true` |
|
|
389
|
+
| `.passive` | `passive: true` (performance hint) |
|
|
390
|
+
| `.self` | Handler runs only when `e.target === e.currentTarget` |
|
|
391
|
+
| `.enter` | Only fires when `Enter` key is pressed |
|
|
392
|
+
| `.escape` | Only fires on `Escape` |
|
|
393
|
+
| `.space` | Only fires on Space |
|
|
394
|
+
| `.tab`, `.delete`, `.backspace` | Corresponding keys |
|
|
395
|
+
| `.up`, `.down`, `.left`, `.right` | Arrow keys |
|
|
396
|
+
| `.a`–`.z`, `.0`–`.9` | Single character key filter |
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
html`
|
|
400
|
+
<form @submit.prevent=${handleSubmit}>
|
|
401
|
+
<input @keydown.enter=${submitOnEnter} />
|
|
402
|
+
<button @click.stop.once=${doOnce}>Once</button>
|
|
403
|
+
</form>
|
|
404
|
+
`
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Conditional rendering
|
|
408
|
+
|
|
409
|
+
Return a `NixTemplate` or `null`/`false` from a function binding:
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
const show = signal(true);
|
|
413
|
+
|
|
414
|
+
html`
|
|
415
|
+
<div>
|
|
416
|
+
${() => show.value
|
|
417
|
+
? html`<p>Visible content</p>`
|
|
418
|
+
: null
|
|
419
|
+
}
|
|
420
|
+
</div>
|
|
421
|
+
`
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
When the condition changes, the previous DOM is fully cleaned up (effects disposed, `onUnmount` called) and the new branch is rendered.
|
|
425
|
+
|
|
426
|
+
### List rendering
|
|
427
|
+
|
|
428
|
+
For simple, stable lists:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
const items = ["Apple", "Banana", "Cherry"];
|
|
432
|
+
|
|
433
|
+
html`
|
|
434
|
+
<ul>
|
|
435
|
+
${items.map(item => html`<li>${item}</li>`)}
|
|
436
|
+
</ul>
|
|
437
|
+
`
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
For reactive lists that change over time, prefer `repeat()`.
|
|
441
|
+
|
|
442
|
+
### Keyed lists: `repeat()`
|
|
443
|
+
|
|
444
|
+
`repeat()` enables efficient diffing: DOM nodes for unchanged keys are preserved and **only** added, removed, or reordered items are touched.
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
import { repeat } from "./nix";
|
|
448
|
+
|
|
449
|
+
const todos = signal([
|
|
450
|
+
{ id: 1, text: "Buy milk" },
|
|
451
|
+
{ id: 2, text: "Write docs" },
|
|
452
|
+
]);
|
|
453
|
+
|
|
454
|
+
html`
|
|
455
|
+
<ul>
|
|
456
|
+
${() => repeat(
|
|
457
|
+
todos.value,
|
|
458
|
+
todo => todo.id, // key function — must be unique
|
|
459
|
+
todo => html`<li>${todo.text}</li>`
|
|
460
|
+
)}
|
|
461
|
+
</ul>
|
|
462
|
+
`
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
**Signature:**
|
|
466
|
+
```typescript
|
|
467
|
+
function repeat<T>(
|
|
468
|
+
items: T[],
|
|
469
|
+
keyFn: (item: T, index: number) => string | number,
|
|
470
|
+
renderFn: (item: T, index: number) => NixTemplate | NixComponent
|
|
471
|
+
): KeyedList<T>
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### DOM refs: `ref()`
|
|
475
|
+
|
|
476
|
+
`ref()` creates a typed container that is filled with the actual DOM element after mount, and cleared on unmount.
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
import { ref } from "./nix";
|
|
480
|
+
|
|
481
|
+
const inputRef = ref<HTMLInputElement>();
|
|
482
|
+
|
|
483
|
+
const tpl = html`<input ref=${inputRef} type="text" />`;
|
|
484
|
+
|
|
485
|
+
mount(tpl, "#app");
|
|
486
|
+
|
|
487
|
+
// inputRef.el is now the <input> element
|
|
488
|
+
inputRef.el?.focus();
|
|
489
|
+
inputRef.el?.value; // ""
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
The `NixRef<T>` type:
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
interface NixRef<T extends Element = Element> {
|
|
496
|
+
el: T | null;
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## Components
|
|
503
|
+
|
|
504
|
+
### Function components
|
|
505
|
+
|
|
506
|
+
The simplest form: a plain function that returns a `NixTemplate`. Signals inside close over the component's scope.
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { html, signal, mount } from "./nix";
|
|
510
|
+
|
|
511
|
+
function Counter() {
|
|
512
|
+
const count = signal(0);
|
|
513
|
+
return html`
|
|
514
|
+
<div>
|
|
515
|
+
<p>${() => count.value}</p>
|
|
516
|
+
<button @click=${() => count.value++}>+</button>
|
|
517
|
+
</div>
|
|
518
|
+
`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
mount(Counter(), "#app");
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Class components: `NixComponent`
|
|
525
|
+
|
|
526
|
+
For components that need lifecycle hooks, extend `NixComponent`:
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
import { NixComponent, html, signal } from "./nix";
|
|
530
|
+
|
|
531
|
+
class Timer extends NixComponent {
|
|
532
|
+
count = signal(0);
|
|
533
|
+
private _id = 0;
|
|
534
|
+
|
|
535
|
+
onMount() {
|
|
536
|
+
this._id = setInterval(() => this.count.update(n => n + 1), 1000);
|
|
537
|
+
return () => clearInterval(this._id); // cleanup
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
render() {
|
|
541
|
+
return html`<span>${() => this.count.value}s</span>`;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
mount(new Timer(), "#app");
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Use class components in templates exactly like any other value:
|
|
549
|
+
|
|
550
|
+
```typescript
|
|
551
|
+
html`<div>${new Timer()}</div>`
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### Lifecycle hooks
|
|
555
|
+
|
|
556
|
+
All hooks are optional:
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
class MyComponent extends NixComponent {
|
|
560
|
+
// ① Called BEFORE render(), no DOM yet.
|
|
561
|
+
// Use it to initialize derived state or call provide().
|
|
562
|
+
onInit() {
|
|
563
|
+
this.derived = computed(() => this.base.value * 2);
|
|
564
|
+
provide(MY_KEY, this.value);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ② Must be implemented. Returns the template. Called once.
|
|
568
|
+
render(): NixTemplate {
|
|
569
|
+
return html`...`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ③ Called AFTER the component is inserted into the DOM.
|
|
573
|
+
// Return a function for automatic cleanup on unmount.
|
|
574
|
+
onMount() {
|
|
575
|
+
const id = addEventListener("resize", this._onResize);
|
|
576
|
+
return () => removeEventListener("resize", this._onResize);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ④ Called BEFORE the component is removed from the DOM.
|
|
580
|
+
onUnmount() {
|
|
581
|
+
console.log("bye!");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ⑤ Catches errors thrown inside onInit() and onMount().
|
|
585
|
+
// If not implemented, errors are re-thrown.
|
|
586
|
+
onError(err: unknown) {
|
|
587
|
+
console.error("Component error:", err);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
**Execution order:**
|
|
593
|
+
|
|
594
|
+
```
|
|
595
|
+
new MyComponent()
|
|
596
|
+
↓
|
|
597
|
+
onInit() ← no DOM, synchronous
|
|
598
|
+
↓
|
|
599
|
+
render() ← returns NixTemplate
|
|
600
|
+
↓
|
|
601
|
+
[DOM inserted]
|
|
602
|
+
↓
|
|
603
|
+
onMount() ← DOM available; return value = cleanup fn
|
|
604
|
+
↓
|
|
605
|
+
...reactive updates...
|
|
606
|
+
↓
|
|
607
|
+
onUnmount() ← DOM still present
|
|
608
|
+
cleanup from onMount()
|
|
609
|
+
↓
|
|
610
|
+
[DOM removed]
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
### `mount()`
|
|
614
|
+
|
|
615
|
+
Mounts a `NixTemplate` or `NixComponent` into the DOM. Returns a handle with an `unmount()` method.
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
// Function component
|
|
619
|
+
const handle = mount(Counter(), "#app");
|
|
620
|
+
|
|
621
|
+
// Class component
|
|
622
|
+
const handle = mount(new Timer(), document.getElementById("app")!);
|
|
623
|
+
|
|
624
|
+
// Unmount later
|
|
625
|
+
handle.unmount(); // runs onUnmount, disposes all effects, removes DOM
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## Dependency Injection
|
|
631
|
+
|
|
632
|
+
Nix.js provides a Vue-style `provide`/`inject` system for passing data down a component tree without prop drilling.
|
|
633
|
+
|
|
634
|
+
### `provide` / `inject`
|
|
635
|
+
|
|
636
|
+
- `provide(key, value)` — call inside `onInit()` to make a value available to all descendant components.
|
|
637
|
+
- `inject(key)` — retrieve the closest provided value for `key`, or `undefined` if none was provided.
|
|
638
|
+
|
|
639
|
+
```typescript
|
|
640
|
+
import { provide, inject, createInjectionKey } from "./nix";
|
|
641
|
+
|
|
642
|
+
const THEME_KEY = createInjectionKey<Signal<string>>("theme");
|
|
643
|
+
|
|
644
|
+
class ThemeProvider extends NixComponent {
|
|
645
|
+
theme = signal("dark");
|
|
646
|
+
|
|
647
|
+
onInit() {
|
|
648
|
+
provide(THEME_KEY, this.theme); // make available to all descendants
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
render() {
|
|
652
|
+
return html`<div>${new ThemedButton()}</div>`;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
class ThemedButton extends NixComponent {
|
|
657
|
+
theme = inject(THEME_KEY); // Signal<string> | undefined
|
|
658
|
+
|
|
659
|
+
render() {
|
|
660
|
+
const style = () =>
|
|
661
|
+
`background:${this.theme?.value === "dark" ? "#1e293b" : "#f0f9ff"}`;
|
|
662
|
+
return html`<button style=${style}>Click me</button>`;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
**Rules:**
|
|
668
|
+
- `provide()` must be called inside `onInit()` (or a constructor), never at the module level.
|
|
669
|
+
- `inject()` searches from the current component up through its ancestors. The **nearest** ancestor wins.
|
|
670
|
+
- Calling `provide()` outside a component context throws an error.
|
|
671
|
+
- Calling `inject()` outside a component context returns `undefined` silently.
|
|
672
|
+
|
|
673
|
+
### `createInjectionKey`
|
|
674
|
+
|
|
675
|
+
Creates a globally unique, typed symbol to use as a key. Typed keys prevent mismatches between provider and consumer.
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
import type { InjectionKey } from "./nix";
|
|
679
|
+
|
|
680
|
+
// Typed key — Signal<string> is the shape of the provided value
|
|
681
|
+
const LOCALE_KEY: InjectionKey<Signal<string>> = createInjectionKey("locale");
|
|
682
|
+
const USER_KEY: InjectionKey<User> = createInjectionKey("user");
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## Global Stores
|
|
688
|
+
|
|
689
|
+
### `createStore`
|
|
690
|
+
|
|
691
|
+
Creates a reactive global store. Every property of the initial state becomes a `Signal`. An optional factory function adds typed actions.
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
import { createStore } from "./nix";
|
|
695
|
+
|
|
696
|
+
// Basic store — no actions
|
|
697
|
+
const theme = createStore({ dark: true, fontSize: 16 });
|
|
698
|
+
|
|
699
|
+
theme.dark.value = false; // write
|
|
700
|
+
theme.fontSize.value; // read
|
|
701
|
+
theme.$reset(); // restore all signals to initial values
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
**With actions:**
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
const cart = createStore(
|
|
708
|
+
{
|
|
709
|
+
items: [] as string[],
|
|
710
|
+
total: 0,
|
|
711
|
+
},
|
|
712
|
+
(s) => ({
|
|
713
|
+
add: (item: string) => s.items.update(arr => [...arr, item]),
|
|
714
|
+
remove: (item: string) => s.items.update(arr => arr.filter(i => i !== item)),
|
|
715
|
+
clear: () => cart.$reset(),
|
|
716
|
+
})
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
cart.add("Milk");
|
|
720
|
+
cart.items.value; // ["Milk"]
|
|
721
|
+
cart.clear();
|
|
722
|
+
cart.items.value; // []
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
**Types:**
|
|
726
|
+
|
|
727
|
+
```typescript
|
|
728
|
+
// StoreSignals<T> — the signals object
|
|
729
|
+
type StoreSignals<T> = { readonly [K in keyof T]: Signal<T[K]> };
|
|
730
|
+
|
|
731
|
+
// Store<T, A> — signals + actions + $reset
|
|
732
|
+
type Store<T, A> = StoreSignals<T> & A & { $reset(): void };
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
## Router
|
|
738
|
+
|
|
739
|
+
A client-side History API router with dynamic parameters, query strings, nested routes, and reactive active-link styling.
|
|
740
|
+
|
|
741
|
+
### `createRouter`
|
|
742
|
+
|
|
743
|
+
Call once at app startup. Sets up the router singleton consumed by `RouterView`, `Link`, and `useRouter`.
|
|
744
|
+
|
|
745
|
+
```typescript
|
|
746
|
+
import { createRouter, RouterView, Link } from "./nix";
|
|
747
|
+
|
|
748
|
+
const router = createRouter([
|
|
749
|
+
{ path: "/", component: () => new HomePage() },
|
|
750
|
+
{ path: "/about", component: () => new AboutPage() },
|
|
751
|
+
{ path: "/users/:id", component: () => new UserDetail() },
|
|
752
|
+
{ path: "*", component: () => new NotFound() },
|
|
753
|
+
]);
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
The `Router` interface exposes:
|
|
757
|
+
|
|
758
|
+
| Property | Type | Description |
|
|
759
|
+
|----------|------|-------------|
|
|
760
|
+
| `current` | `Signal<string>` | Active pathname (`/users/42`) |
|
|
761
|
+
| `params` | `Signal<Record<string, string>>` | Dynamic route params (`{ id: "42" }`) |
|
|
762
|
+
| `query` | `Signal<Record<string, string>>` | Query string params (`{ page: "2" }`) |
|
|
763
|
+
| `navigate(path, query?)` | `void` | Navigate programmatically |
|
|
764
|
+
| `routes` | `RouteRecord[]` | Original route tree |
|
|
765
|
+
|
|
766
|
+
### `RouterView`
|
|
767
|
+
|
|
768
|
+
A `NixComponent` that renders the matched component for a given depth level. Use `new RouterView()` for the root, `new RouterView(1)` for nested child routes.
|
|
769
|
+
|
|
770
|
+
```typescript
|
|
771
|
+
class App extends NixComponent {
|
|
772
|
+
render() {
|
|
773
|
+
return html`
|
|
774
|
+
<nav>
|
|
775
|
+
${new Link("/", "Home")}
|
|
776
|
+
${new Link("/about", "About")}
|
|
777
|
+
</nav>
|
|
778
|
+
${new RouterView()}
|
|
779
|
+
`;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
mount(new App(), "#app");
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### `Link`
|
|
787
|
+
|
|
788
|
+
A reactive `<a>` tag that automatically applies active/inactive styles based on the current route.
|
|
789
|
+
|
|
790
|
+
```typescript
|
|
791
|
+
new Link("/about", "About Us")
|
|
792
|
+
// <a href="/about" style="...active/inactive styles...">About Us</a>
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Clicking a `Link` calls `router.navigate()` and updates the URL via `history.pushState` — no page reload.
|
|
796
|
+
|
|
797
|
+
### `useRouter`
|
|
798
|
+
|
|
799
|
+
Access the router singleton from anywhere — useful inside `NixComponent.render()`:
|
|
800
|
+
|
|
801
|
+
```typescript
|
|
802
|
+
class UserDetail extends NixComponent {
|
|
803
|
+
render() {
|
|
804
|
+
const router = useRouter();
|
|
805
|
+
return html`
|
|
806
|
+
<h1>User: ${() => router.params.value.id}</h1>
|
|
807
|
+
<p>Page: ${() => router.query.value.page ?? "1"}</p>
|
|
808
|
+
`;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### Nested routes
|
|
814
|
+
|
|
815
|
+
Define `children` on a route. The parent component renders `new RouterView(1)` to slot in the child:
|
|
816
|
+
|
|
817
|
+
```typescript
|
|
818
|
+
createRouter([
|
|
819
|
+
{
|
|
820
|
+
path: "/dashboard",
|
|
821
|
+
component: () => new DashboardLayout(),
|
|
822
|
+
children: [
|
|
823
|
+
{ path: "/stats", component: () => new StatsPage() },
|
|
824
|
+
{ path: "/settings", component: () => new SettingsPage() },
|
|
825
|
+
],
|
|
826
|
+
},
|
|
827
|
+
]);
|
|
828
|
+
|
|
829
|
+
class DashboardLayout extends NixComponent {
|
|
830
|
+
render() {
|
|
831
|
+
return html`
|
|
832
|
+
<aside>
|
|
833
|
+
${new Link("/dashboard/stats", "Stats")}
|
|
834
|
+
${new Link("/dashboard/settings", "Settings")}
|
|
835
|
+
</aside>
|
|
836
|
+
<main>${new RouterView(1)}</main> <!-- renders the child route -->
|
|
837
|
+
`;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
### Query parameters
|
|
843
|
+
|
|
844
|
+
```typescript
|
|
845
|
+
const router = useRouter();
|
|
846
|
+
|
|
847
|
+
// Navigate with query params as an object
|
|
848
|
+
router.navigate("/users", { page: 2, sort: "name" });
|
|
849
|
+
// URL: /users?page=2&sort=name
|
|
850
|
+
|
|
851
|
+
// Or inline in the path string
|
|
852
|
+
router.navigate("/users?page=2&sort=name");
|
|
853
|
+
|
|
854
|
+
// Read them reactively
|
|
855
|
+
html`<p>Page: ${() => router.query.value.page}</p>`
|
|
856
|
+
|
|
857
|
+
// null/undefined removes the key
|
|
858
|
+
router.navigate("/users", { page: null });
|
|
859
|
+
// URL: /users
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
---
|
|
863
|
+
|
|
864
|
+
## Async & Lazy Loading
|
|
865
|
+
|
|
866
|
+
### `suspend()`
|
|
867
|
+
|
|
868
|
+
Runs an async function and renders different UIs depending on its state: `pending`, `resolved`, or `error`. The equivalent of `<Suspense>` in other frameworks.
|
|
869
|
+
|
|
870
|
+
```typescript
|
|
871
|
+
import { suspend } from "./nix";
|
|
872
|
+
|
|
873
|
+
const userView = suspend(
|
|
874
|
+
() => fetch("/api/user").then(r => r.json()),
|
|
875
|
+
(user) => html`<div>${user.name}</div>`
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
mount(userView, "#app");
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
**Options:**
|
|
882
|
+
|
|
883
|
+
```typescript
|
|
884
|
+
suspend(
|
|
885
|
+
asyncFn,
|
|
886
|
+
renderFn,
|
|
887
|
+
{
|
|
888
|
+
// Template shown while pending (default: animated spinner)
|
|
889
|
+
fallback: html`<p>Loading…</p>`,
|
|
890
|
+
|
|
891
|
+
// Called with the error if the promise rejects
|
|
892
|
+
errorFallback: (err) => html`<p style="color:red">Error: ${String(err)}</p>`,
|
|
893
|
+
|
|
894
|
+
// If true, shows the fallback on every re-fetch.
|
|
895
|
+
// If false (default), keeps the previous content visible during refresh.
|
|
896
|
+
resetOnRefresh: false,
|
|
897
|
+
}
|
|
898
|
+
)
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
### `lazy()`
|
|
902
|
+
|
|
903
|
+
Wraps a dynamic `import()` for code-splitting. The module chunk is loaded once and cached; subsequent renders use the cached constructor directly.
|
|
904
|
+
|
|
905
|
+
```typescript
|
|
906
|
+
import { createRouter, lazy } from "./nix";
|
|
907
|
+
|
|
908
|
+
createRouter([
|
|
909
|
+
{ path: "/", component: lazy(() => import("./pages/Home")) },
|
|
910
|
+
{ path: "/about", component: lazy(() => import("./pages/About")) },
|
|
911
|
+
{
|
|
912
|
+
path: "/admin",
|
|
913
|
+
component: lazy(
|
|
914
|
+
() => import("./pages/Admin"),
|
|
915
|
+
html`<p>Loading admin panel…</p>` // optional custom fallback
|
|
916
|
+
),
|
|
917
|
+
},
|
|
918
|
+
]);
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
Each page module must export its component as `export default`:
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
// pages/Home.ts
|
|
925
|
+
import { NixComponent, html } from "../nix";
|
|
926
|
+
|
|
927
|
+
export default class HomePage extends NixComponent {
|
|
928
|
+
render() {
|
|
929
|
+
return html`<h1>Home</h1>`;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
---
|
|
935
|
+
|
|
936
|
+
## API Reference
|
|
937
|
+
|
|
938
|
+
### Reactivity
|
|
939
|
+
|
|
940
|
+
| Function | Signature | Description |
|
|
941
|
+
|----------|-----------|-------------|
|
|
942
|
+
| `signal` | `<T>(initial: T) → Signal<T>` | Create a reactive value |
|
|
943
|
+
| `computed` | `<T>(fn: () => T) → Signal<T>` | Derived reactive value |
|
|
944
|
+
| `effect` | `(fn: () => void\|cleanup) → dispose` | Run and re-run on signal changes |
|
|
945
|
+
| `batch` | `(fn: () => void) → void` | Flush multiple writes as one update |
|
|
946
|
+
| `watch` | `(source, cb, opts?) → dispose` | Observe a source, receive old+new values |
|
|
947
|
+
| `untrack` | `<T>(fn: () => T) → T` | Read signals without subscribing |
|
|
948
|
+
| `nextTick` | `(fn?: () => void) → Promise<void>` | Await next microtask (post-DOM-update) |
|
|
949
|
+
|
|
950
|
+
### Signal methods
|
|
951
|
+
|
|
952
|
+
| Method | Description |
|
|
953
|
+
|--------|-------------|
|
|
954
|
+
| `.value` (get) | Read value and subscribe if inside an effect |
|
|
955
|
+
| `.value` (set) | Write and notify if changed |
|
|
956
|
+
| `.update(fn)` | Write via `fn(current) → next` |
|
|
957
|
+
| `.peek()` | Read without subscribing |
|
|
958
|
+
| `.dispose()` | Clear all subscribers |
|
|
959
|
+
|
|
960
|
+
### Templates
|
|
961
|
+
|
|
962
|
+
| Export | Description |
|
|
963
|
+
|--------|-------------|
|
|
964
|
+
| `html\`\`` | Tagged template → `NixTemplate` |
|
|
965
|
+
| `repeat(items, keyFn, renderFn)` | Keyed list with efficient diffing |
|
|
966
|
+
| `ref<T>()` | Create a `NixRef<T>` for direct DOM access |
|
|
967
|
+
|
|
968
|
+
### Components
|
|
969
|
+
|
|
970
|
+
| Export | Description |
|
|
971
|
+
|--------|-------------|
|
|
972
|
+
| `NixComponent` | Abstract base class with lifecycle hooks |
|
|
973
|
+
| `mount(component, container)` | Mount template or component → `{ unmount() }` |
|
|
974
|
+
|
|
975
|
+
### Dependency Injection
|
|
976
|
+
|
|
977
|
+
| Export | Description |
|
|
978
|
+
|--------|-------------|
|
|
979
|
+
| `createInjectionKey<T>(desc?)` | Create a typed, unique injection key |
|
|
980
|
+
| `provide(key, value)` | Register a value (call in `onInit`) |
|
|
981
|
+
| `inject(key)` | Retrieve the nearest provided value |
|
|
982
|
+
| `InjectionKey<T>` | Type for typed injection keys |
|
|
983
|
+
|
|
984
|
+
### Stores
|
|
985
|
+
|
|
986
|
+
| Export | Description |
|
|
987
|
+
|--------|-------------|
|
|
988
|
+
| `createStore(state, actions?)` | Create a reactive global store |
|
|
989
|
+
| `Store<T, A>` | Type of the returned store |
|
|
990
|
+
| `StoreSignals<T>` | Signal-mapped type of a state shape |
|
|
991
|
+
|
|
992
|
+
### Router
|
|
993
|
+
|
|
994
|
+
| Export | Description |
|
|
995
|
+
|--------|-------------|
|
|
996
|
+
| `createRouter(routes)` | Initialize the router singleton |
|
|
997
|
+
| `useRouter()` | Access the active router from anywhere |
|
|
998
|
+
| `RouterView` | Component that renders the matched route |
|
|
999
|
+
| `Link` | Reactive anchor component |
|
|
1000
|
+
| `Router` | Router instance interface |
|
|
1001
|
+
| `RouteRecord` | Route definition type |
|
|
1002
|
+
|
|
1003
|
+
### Async
|
|
1004
|
+
|
|
1005
|
+
| Export | Description |
|
|
1006
|
+
|--------|-------------|
|
|
1007
|
+
| `suspend(asyncFn, renderFn, opts?)` | Async data fetching with Suspense |
|
|
1008
|
+
| `lazy(importFn, fallback?)` | Dynamic import with caching |
|
|
1009
|
+
| `SuspenseOptions` | Options type for `suspend()` |
|
|
1010
|
+
|
|
1011
|
+
---
|
|
1012
|
+
|
|
1013
|
+
## Known Limitations
|
|
1014
|
+
|
|
1015
|
+
**Partial attribute interpolation is not supported.**
|
|
1016
|
+
|
|
1017
|
+
Each dynamic attribute must be a single interpolation covering the entire attribute value. Mixing static text and expressions inside one attribute value does not work:
|
|
1018
|
+
|
|
1019
|
+
```typescript
|
|
1020
|
+
// ✅ Works — the whole value is one expression
|
|
1021
|
+
html`<div class=${() => `item ${isActive.value ? "active" : ""}`}>`
|
|
1022
|
+
|
|
1023
|
+
// ❌ Does NOT work — static prefix + dynamic suffix in same attribute
|
|
1024
|
+
html`<div class="item ${() => isActive.value ? 'active' : ''}">`
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
Workaround: compute the full string outside the template and bind the result.
|
|
1028
|
+
|
|
1029
|
+
---
|
|
1030
|
+
|
|
1031
|
+
## License
|
|
1032
|
+
|
|
1033
|
+
MIT
|