@deijose/nix-ionic
v2.0.7
Published
Ionic lifecycle & router bridge for Nix.js
Maintainers
Readme
@deijose/nix-ionic
Ionic mobile integration for Nix.js — tree-shakeable components, reactive overlays, cache policies, page-state persistence, optional Capacitor, and a Vite plugin for auto-registration.
Why?
@deijose/nix-ionic bridges Nix.js signal-based reactivity with Ionic Core 8's native routing, transitions, and overlays. Unlike @ionic/angular or @ionic/react, it adds zero framework runtime overhead — no virtual DOM, no dependency arrays, no hooks rules.
Install
npm install @deijose/nix-ionic @deijose/nix-js @ionic/core ioniconsFor native mobile (optional):
npm install @capacitor/core @capacitor/app @capacitor/status-bar @capacitor/splash-screen @capacitor/keyboard @capacitor/hapticsQuick start
// main.ts
import "@ionic/core/css/core.css";
import "@ionic/core/css/normalize.css";
import "@ionic/core/css/structure.css";
import "@ionic/core/css/typography.css";
import "@ionic/core/css/padding.css";
import "@ionic/core/css/flex-utils.css";
import "@ionic/core/css/display.css";
import { NixComponent, html, mount } from "@deijose/nix-js";
import { IonRouterOutlet, IonPage, IonBackButton } from "@deijose/nix-ionic";
import { initializeNixIonic, registerIonicComponents } from "@deijose/nix-ionic";
import { defineIonHeader, defineIonToolbar, defineIonTitle, defineIonContent, defineIonButton } from "@deijose/nix-ionic/components";
import { home, homeOutline } from "ionicons/icons";
// 1. Initialize + register only what you use
initializeNixIonic();
registerIonicComponents(defineIonHeader, defineIonToolbar, defineIonTitle, defineIonContent, defineIonButton);
// 2. Define routes
const outlet = new IonRouterOutlet([
{ path: "/", component: () => html`<ion-content><h1>Home</h1></ion-content>` },
{ path: "/detail/:id", component: (ctx) => new DetailPage(ctx) },
]);
// 3. Mount
class App extends NixComponent {
override render() {
return html`<ion-app>${outlet}</ion-app>`;
}
}
mount(new App(), "#app");Subpaths
| Import | What it gives you |
|---|---|
| @deijose/nix-ionic | Core: router outlet, pages, lifecycle, setup, overlays, page-state |
| @deijose/nix-ionic/components/* | Individual component definers (tree-shakeable) |
| @deijose/nix-ionic/bundles/* | Category bundles (layout, forms, lists, etc.) |
| @deijose/nix-ionic/overlays | Reactive overlay controllers |
| @deijose/nix-ionic/page-state | Page-state persistence protocol |
| @deijose/nix-ionic/navigation | NavigationManager (single authority, hooks, tab switching) |
| @deijose/nix-ionic/capacitor | Optional Capacitor integration (zero web bundle cost) |
Testing
- Unit tests:
npm test— 234 tests with happy-dom mocks - E2E tests:
npm run e2e— 12 Playwright tests with real@ionic/core- Navigation, lifecycle, overlays, back button, contract tests
- Chromium mobile viewport,
prefers-reduced-motion: reduce - No mocks — real
ion-router-outlet.commit(), real custom elements |@deijose/nix-ionic/vite-plugin| Vite plugin for auto component/icon registration |
Setup
Incremental (recommended)
import { initializeNixIonic, registerIonicComponents, registerIonicons } from "@deijose/nix-ionic";
import { defineIonButton, defineIonCard } from "@deijose/nix-ionic/components/button";
import { star, starOutline } from "ionicons/icons";
initializeNixIonic();
registerIonicComponents(defineIonButton, defineIonCard);
registerIonicons({ star, "star-outline": starOutline });Compatibility facade (1.x migration)
import { setupNixIonic } from "@deijose/nix-ionic";
import { allComponents } from "@deijose/nix-ionic/bundles/all";
setupNixIonic({ components: allComponents });Vite plugin (auto-registration)
// vite.config.ts
import { defineConfig } from "vite";
import { nix } from "@deijose/vite-plugin-nix-js";
import { nixIonic } from "@deijose/nix-ionic/vite-plugin";
export default defineConfig({
plugins: [
nix(),
nixIonic(), // scans html`` for <ion-*> tags + static icons
],
});
// Then in your app entry:
import "virtual:nix-ionic/registration";The plugin scans html\`templates for<ion-*>tags andname="icon-name"` attributes, then generates a virtual module that imports and registers only what you use.
Pages
Class component with lifecycle
import { html, signal } from "@deijose/nix-js";
import { IonPage, IonBackButton, type PageContext } from "@deijose/nix-ionic";
class DetailPage extends IonPage {
private data = signal<unknown>(null);
private id: string;
constructor({ lc, params }: PageContext) {
super(lc);
this.id = params.id;
}
override ionViewWillEnter() {
// Runs on every activation — even from cache
fetch(`/api/items/${this.id}`).then(r => r.json()).then(d => this.data.value = d);
}
override ionViewWillLeave() {
// Pause timers, subscriptions, etc.
}
override render() {
return html`
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">${IonBackButton()}</ion-buttons>
<ion-title>Detail</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>${() => JSON.stringify(this.data.value)}</p>
</ion-content>
`;
}
}Function component with composables
import { html, signal } from "@deijose/nix-js";
import { useIonViewWillEnter, useIonViewWillLeave, type PageContext } from "@deijose/nix-ionic";
function ProfilePage({ lc }: PageContext) {
const visits = signal(0);
useIonViewWillEnter(lc, () => visits.value++);
useIonViewWillLeave(lc, () => console.log("leaving"));
return html`<ion-content><p>Visits: ${() => visits.value}</p></ion-content>`;
}Lifecycle hooks
| Hook | When | Use for |
|---|---|---|
| ionViewWillEnter | Before visible (every activation) | Data refresh, restart timers |
| ionViewDidEnter | After fully visible | Analytics, scroll position |
| ionViewWillLeave | Before hidden | Pause timers, save state |
| ionViewDidLeave | After hidden | Cleanup subscriptions |
Key:
onMount/onInitfire once.ionViewWillEnterfires on every visit — even from cache.
Navigation
import { nixRouter } from "@deijose/nix-js";
const router = nixRouter();
router.navigate("/detail/42");
router.navigate("/search", { query: { q: "hello" } });
router.replace("/home");
router.back();
router.current.value; // "/detail/42"
router.params.value; // { id: "42" }
router.query.value; // { q: "hello" }
router.canGoBack.value; // true/falseRoute guards
new IonRouterOutlet([
{
path: "/admin",
component: (ctx) => new AdminPage(ctx),
beforeEnter: ({ params }) => {
if (!isLoggedIn()) return "/login"; // redirect
if (!isAdmin()) return false; // cancel
// void/undefined = allow
},
},
]);Cache policies
Control how many pages stay cached and for how long.
new IonRouterOutlet(routes, {
cachePolicy: { max: 10, ttl: 60000, strategy: "lru" },
});| Option | Default | Description |
|---|---|---|
| max | unlimited | Max cached entries per tab. Excess evicted by strategy. |
| ttl | unlimited | Time-to-live in ms. Entries auto-evicted on expiry. |
| strategy | "lru" | "lru" = evict least-recently-used, "fifo" = evict oldest |
Per-route override
new IonRouterOutlet([
{ path: "/", component: () => html`...` },
{ path: "/transient", cache: false, component: () => html`...` }, // never cache
{ path: "/heavy", cache: { max: 1 }, component: () => html`...` }, // max 1 instance
{ path: "/volatile", cache: { ttl: 5000 }, component: () => html`...` }, // 5s TTL
]);NavigationManager
A single coordination authority for navigation — hooks, tab switching, and cache invalidation by route pattern.
import { NavigationManager, IonRouterOutlet } from "@deijose/nix-ionic";
const nav = new NavigationManager({ tabs: ["/home", "/search", "/profile"] });
// Navigation hooks
nav.beforeNav((path, intent) => {
console.log("navigating to", path);
// return false to cancel
});
nav.afterNav((path, direction) => {
analytics.track("page_view", { path, direction });
});
nav.onTabChange((tab, prev) => {
console.log("tab changed:", prev, "→", tab);
});
// Programmatic tab switching
const target = nav.switchTab("/search");
if (target) nixRouter().navigate(target);
// Cache invalidation by route pattern
nav.invalidateRoute("/user/:id", { id: "42" }); // invalidate user 42
nav.invalidatePattern("/admin/*"); // invalidate all admin pages
// Pass to outlet
const outlet = new IonRouterOutlet(routes, { navigation: nav });Stack inspection
nav.stackDepth(); // current tab stack depth
nav.stackTop(); // current tab stack top path
nav.stackEntries(); // copy of current tab stack
nav.activeTab; // active tab prefixOverlays
Reactive overlay controllers using the create* pattern (like createStore, createRouter).
createPopover() and createModal() automatically inject a Nix.js framework
delegate when component is a function (e.g. () => html\...``), so you can
pass Nix.js templates as overlay content without any extra setup.
import { html } from "@deijose/nix-js";
import { createToast, createAlert, createModal, createPopover, confirm, withLoading } from "@deijose/nix-ionic";
function MyPage() {
const toast = createToast();
const modal = createModal();
const popover = createPopover();
const save = async () => {
await withLoading({ message: "Saving..." }, async () => {
await fetch("/api/save", { method: "POST" });
});
toast.present({ message: "Saved!", duration: 1500 });
};
const openModal = () => modal.present({
component: () => html`<ion-content><h1>Modal content mounted by Nix.js!</h1></ion-content>`,
});
const openPopover = (event: Event) => popover.present({
event,
component: () => html`
<div style="padding: 20px;">
<ion-button @click=${() => popover.dismiss()}>Close</ion-button>
</div>
`,
});
return html`
<ion-content>
<ion-button @click=${save}>Save</ion-button>
<ion-button @click=${openModal}>Open Modal</ion-button>
<ion-button @click=${(e: Event) => openPopover(e)}>Open Popover</ion-button>
${() => modal.presented.value ? html`<p>Modal is open</p>` : null}
</ion-content>
`;
}Note: The framework delegate is injected automatically when
componentis a function. If you pass a string (tag name) orHTMLElement, no delegate is needed and Ionic handles it natively.
Available controllers
| Controller | Description |
|---|---|
| createToast() | Reactive toast |
| createAlert() | Reactive alert |
| createLoading() | Reactive loading spinner |
| createActionSheet() | Reactive action sheet |
| createPopover() | Reactive popover (auto-injects Nix.js delegate for component: () => html\...`) |
| createModal()| Reactive modal (auto-injects Nix.js delegate forcomponent: () => html`...`) |
| createPicker()| Column-based picker (lazy-registersion-picker-legacy`) |
One-shot helpers
import { showToast, withLoading, confirm } from "@deijose/nix-ionic";
showToast({ message: "Done!", duration: 1000 });
const data = await withLoading({ message: "Fetching..." },
() => fetch("/api/data").then(r => r.json()));
const yes = await confirm({ header: "Delete", message: "Sure?", confirmText: "Delete" });Page-state persistence
Opt-in persistence of serializable state across navigation and app restarts.
import { signal } from "@deijose/nix-js";
import { createPageState, IonPage } from "@deijose/nix-ionic";
class SearchPage extends IonPage {
private query = signal("");
private results = signal<string[]>([]);
private state = createPageState("search", {
query: this.query,
results: this.results,
}, { storage: "local" }); // persists across app restarts
override ionViewWillEnter() { this.state.restore(); }
override ionViewWillLeave() { this.state.save(); }
}Rules:
- Only serializable data (primitives, plain arrays/objects)
- DOM nodes, functions, symbols, class instances → rejected with warning
sessionStorage(default) orlocalStorage(opt-in)clearAllPageState()for logout flows
Capacitor (optional native)
Isolated behind @deijose/nix-ionic/capacitor — zero web bundle cost (0 bytes of @capacitor/* in main bundle).
import { createCapacitorApp } from "@deijose/nix-ionic/capacitor";
const app = createCapacitorApp({
statusBar: { style: "dark", backgroundColor: "#1a1a2e" },
splashScreen: { fadeOutDuration: 200 },
backButton: { defaultHref: "/home" },
});
await app.ready(); // configures status bar, hides splash, wires back buttonIndividual plugins
import { Haptics, StatusBar, App, Keyboard } from "@deijose/nix-ionic/capacitor";
await Haptics.impact("medium"); // no-op on web
await StatusBar.setStyle({ style: "dark" }); // no-op on web
App.onBackButton((info) => { /* ... */ }); // no-op on webAll methods are no-ops on web — safe to call unconditionally.
Tabs
import { createBottomTabBar, createTabsLayout, IonRouterOutlet, NavigationManager } from "@deijose/nix-ionic";
import { home, search, person, settings } from "ionicons/icons";
const nav = new NavigationManager({ tabs: ["/", "/search", "/profile", "/settings"] });
const outlet = new IonRouterOutlet(routes, {
tabs: ["/", "/search", "/profile", "/settings"],
navigation: nav,
});
const tabBar = createBottomTabBar([
{ path: "/", label: "Home", icon: "home", exact: true },
{ path: "/search", label: "Search", icon: "search" },
{ path: "/profile", label: "Profile", icon: "person", badge: "!" },
{ path: "/settings", label: "Settings", icon: "settings" },
], {
hiddenPaths: ["/detail/*", "/profile/edit"],
icons: { home, search, person, settings },
cssVars: {
"--background": "var(--app-tab-bg)",
"--color-selected": "var(--ion-color-primary)",
},
});
// Wrap outlet + tab bar in <ion-tabs> with correct CSS layout
const tabsLayout = createTabsLayout(outlet, tabBar);
html`<ion-app>${tabsLayout}</ion-app>`;How tabs work
- Navigation is driven by the Nix.js router, not Ionic's internal tab
selection. Each
ion-tab-buttonhas@click.prevent.stopto prevent Ionic'sselect()(which expects<ion-tab>children we don't use). createTabsLayout()wraps the outlet and tab bar in<ion-tabs>and injects a small CSS snippet to ensure the tab bar sits at the bottom and the outlet fills the remaining space.- Tab IDs are derived from the path:
/→root,/search→search,/profile/edit→profile-edit. Override withtabId. selectedstate is set via JS property ((btn as any).selected = isActive) using aref+effect+nextTick, because Stencil boolean props can't be set via HTML attributes with Nix.js.
Tab bar options
| Option | Type | Description |
|---|---|---|
| slot | "top" \| "bottom" | Tab bar position (default: "bottom") |
| layout | TabButtonLayout | Icon/label layout (default: "icon-top") |
| hiddenPaths | string[] | Paths where tab bar is hidden (supports * wildcards) |
| hideWhen | (path: string) => boolean | Dynamic hide callback |
| icons | Record<string, IconDefinition> | Icon SVG data for tab icons |
| cssVars | Record<string, string> | CSS custom properties on ion-tab-bar |
| direction | NavigationDirection | Navigation direction on tab switch (default: "none") |
Tab item options
| Option | Type | Description |
|---|---|---|
| path | string | Route path |
| label | string | Tab label text |
| icon | string | Icon name (kebab-case) |
| activeIcon | string | Icon name when active (optional) |
| exact | boolean | Exact path match (default: false) |
| tabId | string | Override auto-generated tab ID |
| badge | string \| number | Badge content |
| badgeColor | string | Badge color (default: "danger") |
Vite plugin
Auto-registers only the Ionic components and icons you actually use in html\`` templates.
// vite.config.ts
import { nixIonic } from "@deijose/nix-ionic/vite-plugin";
export default defineConfig({
plugins: [nixIonic()],
});// app entry — imports the auto-generated virtual module
import "virtual:nix-ionic/registration";Features:
- Scans
html\`for<ion-*>` tags → generates direct subpath imports - Scans
name="icon-name"on<ion-icon>→ generatesionicons/iconsimports - Warns on dynamic tags/icons (with allowlist suppression)
nixIonic({ allowTags: [...], allowIcons: [...] })for dynamic usage
allowTags and allowIcons (important for lazy-loaded pages)
The Vite plugin scans your source files for <ion-*> tags and name="icon-name"
attributes, then generates a virtual module that imports and registers only what
you use. However, in dev mode, Vite loads modules on-demand. The virtual module
is served when your app entry imports it at startup, but pages loaded lazily by
the router may not have been scanned yet.
Use allowTags and allowIcons to explicitly list tags/icons used in
lazy-loaded pages. The plugin emits warnings when it detects tags or icons
that are not in the allowlists, so you know exactly what to add:
[nix-ionic] Tags used in src/pages/HomePage.ts but not in allowTags:
ion-header, ion-toolbar, ion-title, ion-content, ion-button, ...
Add them to `nixIonic({ allowTags: [...] })` to ensure they are
registered before first use.
[nix-ionic] Icons used in src/pages/HomePage.ts but not in allowIcons:
flash-outline, leaf-outline, toast-outline, ...
Add them to `nixIonic({ allowIcons: [...] })` to ensure they are
registered before first use.If new tags/icons are discovered after the registration module was already served, an additional warning is emitted:
[nix-ionic] New tags discovered after registration: ion-header, ...
These were NOT included in the registration module. Add them to
allowTags and reload.Example with full allowlists:
// vite.config.ts
import { defineConfig } from "vite";
import { nix } from "@deijose/vite-plugin-nix-js";
import { nixIonic } from "@deijose/nix-ionic/vite-plugin";
export default defineConfig({
plugins: [
nix(),
nixIonic({
allowTags: [
"ion-app", "ion-header", "ion-toolbar", "ion-title", "ion-content",
"ion-buttons", "ion-button", "ion-back-button",
"ion-tab-bar", "ion-tab-button",
"ion-list", "ion-item", "ion-label",
"ion-card", "ion-card-content",
"ion-input", "ion-toggle", "ion-select", "ion-select-option",
"ion-icon", "ion-chip", "ion-badge",
// Overlays (programmatic)
"ion-toast", "ion-alert", "ion-loading",
"ion-action-sheet", "ion-popover", "ion-modal",
],
allowIcons: [
"home", "search", "person", "settings",
"arrow-back", "arrow-forward",
"share-outline", "link-outline",
// ... all icons used in your templates
],
}),
],
});API reference
Core
| Export | Description |
|---|---|
| IonRouterOutlet | Router outlet with cache policies, guards, tabs |
| IonPage | Base class for pages with lifecycle hooks |
| IonBackButton(defaultHref?) | Back button component |
| createBottomTabBar(tabs, opts?) | Bottom tab bar |
| initializeNixIonic() | Initialize Ionic Core (incremental) |
| registerIonicComponents(...definers) | Register specific components |
| registerIonicons(icons) | Register specific icons |
| setupNixIonic(opts?) | 1.x compatibility facade |
| createPageState(pageId, signals, opts?) | Page-state persistence |
| clearAllPageState(backend?, ns?) | Clear all persisted state |
| NavigationManager | Single navigation authority (hooks, tabs, invalidation) |
| StackManager | Per-tab navigation stack management |
Overlays
| Export | Description |
|---|---|
| createToast() / createAlert() / createLoading() | Reactive overlay controllers |
| createActionSheet() / createPopover() / createModal() | Reactive overlay controllers |
| createPicker() | Column-based picker (lazy-registers ion-picker-legacy) |
| showToast(opts) | One-shot toast |
| withLoading(opts, task) | Loading + async task + auto-dismiss |
| confirm(opts) | Promise-based confirm dialog |
Capacitor (@deijose/nix-ionic/capacitor)
| Export | Description |
|---|---|
| createCapacitorApp(opts) | Bootstrap helper (status bar, splash, back button) |
| StatusBar / SplashScreen / Keyboard | Plugin wrappers (no-op on web) |
| Haptics / App | Plugin wrappers (no-op on web) |
| isNative() / isWeb() | Platform detection |
Vite plugin (@deijose/nix-ionic/vite-plugin)
| Export | Description |
|---|---|
| nixIonic(opts?) | Vite plugin for auto-registration |
| generateRegistrationModule(tags, icons, opts) | Code generator (for testing) |
Comparison
| Feature | @ionic/angular | @ionic/react | @deijose/nix-ionic |
|---|---|---|---|
| Virtual DOM | Angular | React | None |
| Bundle size overhead | Angular runtime | React runtime | Zero |
| Tree-shakeable components | No | No | Yes |
| Auto-registration plugin | No | No | Yes (Vite) |
| Cache policies (LRU/TTL) | No | No | Yes |
| Page-state persistence | Manual | Manual | Built-in |
| Capacitor integration | External | External | Optional subpath |
| Reactive overlays | Manual | Manual | create* pattern |
Testing
All tests run against real @ionic/core (no mocks).
| Suite | Tests | What it covers |
|---|---|---|
| Unit (Vitest) | 235 | Cache policies, navigation, overlays, page-state, Capacitor, Vite plugin, bundles |
| E2E application | 19 | Navigation, lifecycle, overlays (toast/modal/alert/loading/action-sheet/picker), back button |
| E2E contract | 24 | Custom elements, commit(), lifecycle events, overlay present/dismiss, properties, form events, network isolation |
| E2E accessibility/leaks | 13 | ARIA roles, shadow DOM, focus management, repeated navigation, overlay disposal, listener cleanup |
Total: 291 tests, all passing.
Bundle validation
npm run measure-bundlesValidates tree-shaking with 4 fixtures:
| Fixture | Gzip | Purpose |
|---|---|---|
| minimal (1 component) | 0.82 KB | Only ion-button — no other component code |
| partial (layout + buttons) | 1.05 KB | Two bundles — no forms/lists/overlays |
| full (all + overlays + nav) | 7.26 KB | Worst-case bundle |
| capacitor-only | 0.59 KB | Zero @capacitor/* in web bundle |
Minimal = 11.3% of full bundle (gzip) — tree-shaking works.
Limitations
iOS swipe-back gesture
iOS swipe-back (interactive pop gesture) is not supported. The integration
does not assign swipeHandler on ion-router-outlet. Native swipe-back requires
a WebKit/mobile test that reproduces both a completed swipe and a cancelled
swipe with correct lifecycle rollback — this has not been demonstrated.
Use ion-back-button or programmatic router.back() for back navigation.
License
MIT
