@knno/jsx-router
v1.2.0
Published
Reactive router for @knno/jsx — hash & history modes, <Router>, <Route>, <Outlet>, navigation guards
Maintainers
Readme
@knno/jsx-router
Reactive router for @knno/jsx. Hash mode (default) or HTML5 history. Single instance per app, fine-grained reactive updates.
import { Router, Route, Outlet } from '@knno/jsx-router';
render(() => (
<Router>
<Route path="/" element={() => <Home />} />
<Route path="/users" element={() => <UsersLayout />}>
<Route path="/:id" element={({ id }) => <UserProfile id={id} />} />
</Route>
</Router>
), '#app');Install
npm install @knno/jsx-routerRequires @knno/jsx as peer dependency.
API
<Router>
Root component. One per app.
<Router>
<Route path="/" element={() => <Home />} />
</Router>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| mode | 'hash' \| 'history' | 'hash' | Hash fragment or HTML5 History API |
| base | string | — | Base path prefix, e.g. '/app' |
| layout | (child) => unknown | — | Global layout wrapped around every matched route |
| beforeEach | GuardFn \| GuardFn[] | — | Global guard(s) — run before navigation |
| afterEach | GuardFn \| GuardFn[] | — | Global guard(s) — run after successful navigation |
History mode: clean URLs (/users/42), requires server-side SPA fallback (serve index.html for all routes).
base prefix: all routes are matched under the prefix. Use for apps mounted under a sub-path.
<Route>
Defines a route. element must be a function (knno JSX creates DOM immediately — function defers to match time).
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| path | string | — | URL pattern. :param segments, * wildcard |
| element | (() => unknown) \| ((params) => unknown) | — | Called when route matches, receives merged URL params |
| children | Route[] (via JSX nesting) | — | Nested child routes |
| title | string \| (params) => string | — | Set document.title on match |
| breadcrumb | string \| (params) => string | — | Breadcrumb label. Falls back to title if not set |
| beforeEnter | GuardFn | — | Route-level guard |
| meta | Record<string, unknown> | — | Custom metadata |
KeepAlive
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| keepAlive | boolean | — | Cache page DOM in memory. Preserves subscriptions and scroll position across navigation |
Transition
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| enterClass | string | — | CSS class added to entering content |
| leaveClass | string | — | CSS class added to leaving content |
Data Loading
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| loader | (params) => Promise<Record<string, unknown>> | — | Fetch data before rendering. Result merged into element params |
| loading | () => unknown | — | Component shown while loader is pending |
| refresh | boolean \| number | — | Stale strategy: true = always refresh, number = stale after N seconds. Buffered routes only |
| errorElement | () => unknown | — | Component shown when loader rejects |
Scroll
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| scrollRestore | boolean | true | Restore scroll position when navigating back |
<Outlet>
Renders the matched child route. No props needed.
function UsersLayout() {
return (
<div>
<nav>Users</nav>
<section><Outlet /></section>
</div>
);
}Optional fallback when no child matches:
<Outlet><p>Select an item</p></Outlet><Navigate>
Redirect component. Creates an effect that navigates on first render.
<Route path="/old-path" element={() => <Navigate to="/new-path" />} />
<Route path="/login" element={() => <Navigate to="/dashboard" replace />} />navigate() · push() · replace() · reload()
Programmatic navigation. Available before Router is created (falls back to setting URL directly).
import { navigate, push, replace, reload } from '@knno/jsx-router';
<button onClick={() => navigate('/users/42')}>Go</button>
<button onClick={() => navigate('/users/42', { replace: true })}>Go (replace)</button>
<button onClick={() => replace('/users/99')}>Replace</button>
<button onClick={() => reload()}>Reload current route (re-runs loaders)</button>
reload(): Re-executes the current route's rendering — useful for re-running loaders. Same-pathnavigate()doesn't trigger a re-render (hash doesn't change). Usereload()when you need to refresh the current page's data.
path() · pathParams() · searchParams()
Read current URL reactively. Wrap in arrow function for JSX reactivity.
import { path, pathParams, searchParams } from '@knno/jsx-router';
<span>{() => path()}</span> {/* /users/42 */}
<span>{() => pathParams().id}</span> {/* 42 */}
<span>{() => searchParams().page}</span> {/* 1 (from ?page=1) */}All three return plain values (not signals) — wrap in {() => ...} for reactivity.
breadcrumbs()
Returns breadcrumb items for the current route chain. Each item has { label, path }. A route is included if it has breadcrumb or title — breadcrumb takes priority.
import { breadcrumbs } from '@knno/jsx-router';
// In reactive JSX
<nav>
{() => breadcrumbs().map(item => (
<a href={`#${item.path}`}>{item.label}</a>
))}
</nav>Define breadcrumbs on routes:
<Route path="/" element={() => <Home />} breadcrumb="Home">
<Route path="/users" element={() => <Users />} breadcrumb="Users">
<Route path="/:id" element={({ id }) => <User id={id} />} title={({ id }) => `User ${id}`} />
</Route>
</Route>Navigating to /users/42 returns:
[
{ label: 'Home', path: '/' },
{ label: 'Users', path: '/users' },
{ label: 'User 42', path: '/users/42' },
]Rules:
breadcrumbis checked first; if undefined, falls back totitle- If both are undefined, the route is skipped
- Function form receives merged URL params (same as
title) - Returns empty array if router is not mounted yet
isLoading()
Reactive getter — true when any route loader is pending. Use in global layout:
import { isLoading } from '@knno/jsx-router';
function AppLayout({}, children: () => unknown) {
return (
<div>
{() => isLoading() ? <div class="loading-bar" /> : null}
<Outlet />
</div>
);
}Subscriptions clean up automatically when the element is removed from DOM.
requiresAuth()
Guard factory — redirects to /login if check() returns false.
import { requiresAuth } from '@knno/jsx-router';
<Route
path="/admin"
element={() => <Admin />}
beforeEnter={requiresAuth(() => userStore.isLoggedIn)}
/>Navigation Guards
Control navigation by returning false (cancel) or a string (redirect path).
Global beforeEach
<Router
beforeEach={(to, from) => {
if (!store.isLoggedIn && to !== '/login') return '/login';
}}
>Multiple guards run in order — first returned value short-circuits:
<Router beforeEach={[authGuard, roleGuard]}>Global afterEach
Runs after every successful navigation. Cannot cancel.
<Router afterEach={(to, from) => analytics.track('pageview', { page: to })}>Route-level beforeEnter
<Route path="/admin" element={() => <Admin />}
beforeEnter={(to, from) => store.isAdmin ? undefined : '/login'}
/>Execution order
beforeEach(global, array order)beforeEnter(route-level, bottom-up through matched chain)- Navigation executes (URL updates)
- Routing effect fires (trie → chain → Outlet rendering)
afterEach(global)
KeepAlive (keepAlive)
Preserves page DOM in memory. Scroll position, signal subscriptions, and loaded data all persist.
<Route path="/users" element={() => <Users />} keepAlive>
<Route path="/:id" element={({ id }) => <User id={id} />} keepAlive />
</Route>Navigating /users/42 → /users/99: both pages cached in the same-depth outlet. Navigating up to /about: parent-level cache handles swap.
Scroll position is automatically saved and restored (opt out with scrollRestore={false}).
Transitions (enterClass / leaveClass)
CSS-driven enter/leave animations on route changes.
<Route
path="/users/:id"
element={({ id }) => <Profile id={id} />}
enterClass="fade-in"
leaveClass="fade-out"
/>.fade-in {
animation: fadeIn 0.3s ease;
position: absolute; top: 0; left: 0; width: 100%;
}
.fade-out {
animation: fadeOut 0.3s ease;
position: absolute; top: 0; left: 0; width: 100%;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }Old and new content coexist during animation. Framework detects animation via getComputedStyle and resolves immediately if no animation is defined.
Data Loading (loader / refresh / errorElement)
Fetch data before rendering. Result is merged into element params.
<Route
path="/users/:id"
element={({ id, profile }) => <Profile id={id} data={profile} />}
loader={({ id }) => fetch(`/api/users/${id}`).then(r => r.json())}
loading={() => <Spinner />}
errorElement={() => <ErrorPage />}
/>Non-buffered routes: loader runs every time the route is entered. Shows loading component while pending, then renders element with merged data.
Buffered routes: loader runs once on first visit. On return, refresh controls whether to re-fetch:
| refresh | Behavior |
|-----------|----------|
| false (default) | Never re-fetch — use cached data |
| true | Always re-fetch on return |
| 30 | Re-fetch if data is older than 30 seconds |
Background refresh shows old content while loading — no flash. Use isLoading() to show a global indicator.
Scroll Restoration
Enabled by default. Saves window.scrollY when leaving a route, restores on return. Works best with keepAlive.
// Disable for specific routes
<Route path="/" element={() => <Home />} scrollRestore={false} />Route Patterns
Nested routes with layout
<Route path="/dashboard" element={() => <DashboardLayout />}>
<Route path="/" element={() => <Overview />} />
<Route path="/settings" element={() => <Settings />} />
</Route>DashboardLayout renders its child via <Outlet />.
Switch pattern (child replaces parent)
<Route path="/items" element={(p: RouteParams & { outlet?: unknown }) => {
if (p.outlet) return (p.outlet as () => unknown)();
return <ItemList />;
}}>
<Route path="/:id" element={({ id }) => <ItemDetail id={id} />} />
</Route>URL params & wildcard
<Route path="/users/:id/posts/:slug" element={({ id, slug }) => <Post id={id} slug={slug} />} />
<Route path="/files/*" element={({ '*': rest }) => <FileBrowser path={rest} />} />Splitting routes across files
// routes/users.tsx
export const userRoutes = [
<Route path="/users" element={() => <UsersLayout />}>
<Route path="/:id" element={({ id }) => <User id={id} />} />
</Route>,
];// Router.tsx
<Router>
<Route path="/" element={() => <Home />} />
{userRoutes}
</Router>History Mode & Base Path
// History mode — clean URLs
<Router mode="history">
<Route path="/about" element={() => <About />} />
</Router>// Base path prefix
<Router base="/app">
<Route path="/dashboard" element={() => <Dash />} />
</Router>
// Matches: /app/dashboardHistory mode requires server-side SPA fallback (serve index.html for all paths).
TypeScript
import type { RouteParams, GuardFn, BreadcrumbItem } from '@knno/jsx-router';
// Element function
<Route path="/:id" element={({ id }: RouteParams) => <User id={id} />} />
// Outlet + params
<Route path="/items" element={(p: RouteParams & { outlet?: unknown }) => {
if (p.outlet) return (p.outlet as () => unknown)();
return <ItemList />;
}} />
// Guard
const guard: GuardFn = (to, from) => { /* ... */ };
// Breadcrumbs
const items: BreadcrumbItem[] = breadcrumbs();
// { label: string; path: string }