@deijose/nix-js 0.1.2 → 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.
@@ -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