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