@zakkster/lite-router
v1.3.0
Published
Zero-GC, sub-2KB SPA router for the lite-signal ecosystem. URL pathname, query params, and route matches as fine-grained reactive signals — components re-render only when their slice of the URL actually changes.
Maintainers
Readme
@zakkster/lite-router
The URL as fine-grained signals. Components re-render only when their slice of the URL actually changes.
One trunk of reactive state — pathname, hash, query params — derived into per-route, per-param signals. A widget that reads ?sort does not wake when ?page moves. A /users/:id view does not re-run when the query string changes. No virtual DOM, no diffing, no subscriber broadcast: lite-signal's Object.is equality gate stops propagation at the exact node whose value didn't change.
import { route, queryParam, navigate, interceptLinks } from '@zakkster/lite-router';
import { effect } from '@zakkster/lite-signal';
const userRoute = route('/users/:id');
const sort = queryParam('sort');
effect(() => {
const m = userRoute();
if (m) renderUser(m.id); // runs only while on /users/:id
});
effect(() => {
applySort(sort()); // runs only when ?sort changes — never on ?page
});
interceptLinks(); // <a href="/users/42"> now routes client-side
navigate('/users/42?sort=desc'); // or go programmaticallyMeasured on this machine (Node 22, one run — re-run npm run bench; ratios are stable across hardware):
- ~500,000 two-param route matches per second; ~850,000 static matches per second
- ~0 bytes retained per navigation — steady-state navigation does not grow the heap
- 16× fewer downstream re-renders than a router that notifies every subscriber on every navigation (16 independent widgets, one param changed per nav → exactly 1 wakeup, not 16)
Contents
- Why · Install · Quick start
- How it works
- The unique part: surgical updates
- API reference
- Recipes
- Benchmarks
- Testing (for clients & QA)
- Running the demo
- Browser & engine compatibility
- Edge cases & guarantees
- FAQ · License
Why
Most SPA routers model the URL as one event. Something changes, a 'route' event fires, and everyone subscribed re-runs — your route component, your sidebar, your sort control, your pagination — whether or not their input actually moved. With 20 subscribers and a single ?page change, that's 19 needless re-renders. It looks like this:
// The router you reach for first
router.on('change', (url) => {
renderUserPage(url); // re-runs even if only ?page changed
renderSidebar(url); // re-runs even though it ignores the query string
renderSortControl(url); // re-runs even though ?sort didn't move
// ... every subscriber, every navigation
});The fix is not a faster diff — it's not broadcasting in the first place. lite-router puts the URL into a reactive graph where each consumer subscribes to exactly the slice it reads, and propagation halts at any node whose value is Object.is-equal to before.
flowchart LR
subgraph N["Naive router — broadcast"]
direction TB
N1["URL changes"] --> N2["'change' event"]
N2 --> N3["sub A re-runs"]
N2 --> N4["sub B re-runs"]
N2 --> N5["sub C re-runs"]
N3 -.->|"wasted if A's input<br/>didn't change"| N6["render"]
N4 -.->|wasted| N6
N5 -.->|wasted| N6
end
subgraph L["lite-router — fine-grained"]
direction TB
L1["URL changes"] --> L2["trunk signals .set()"]
L2 -->|"Object.is gate"| L3["only changed<br/>slices propagate"]
L3 --> L4["just the affected<br/>consumers re-run"]
endIt's ~200 lines on top of @zakkster/lite-signal. No history library, no path-ranking trie, no component model. It gives you the URL as signals and gets out of the way.
What this is not
- Not a framework. No components, no JSX, no rendering. You bring the rendering; it tells you when and with what.
- Not a nested-route resolver.
route()returns a match-or-nullsignal. Compose your own layout logic from those — it's just boolean signal math. - Not a server router. Client-side
historyAPI only. (It imports cleanly under Node/SSR — it just no-ops without awindow.)
Install
npm i @zakkster/lite-router @zakkster/lite-signalESM-only. @zakkster/lite-signal (the reactive core) is a peer dependency:
install it alongside so your app and the router share one reactive graph. Two
copies would be two graphs, and the router's signals would never drive your
effects. The peer range is ^1.1.0.
import { route, queryParam, navigate, pathname, hash, query, interceptLinks } from '@zakkster/lite-router';You can also drop the four files in src/ into your project directly — no build step.
Quick start
import { route, queryParam, navigate, interceptLinks } from '@zakkster/lite-router';
import { effect } from '@zakkster/lite-signal';
// 1. Define route matchers — each is a computed signal of params | null.
const home = route('/');
const user = route('/users/:id');
const notFound = route('*');
// 2. React to them. The effect re-runs only when its match result changes.
effect(() => {
if (user()) mount(UserView, user().id);
else if (home()) mount(HomeView);
else if (notFound()) mount(NotFoundView);
});
// 3. Read query params individually — independent reactive sources.
const page = queryParam('page');
effect(() => paginate(Number(page() ?? 1))); // only fires when ?page changes
// 4. Wire up links and navigation.
interceptLinks(); // delegate <a> clicks to the router
navigate('/users/42?page=2'); // push
navigate('/login', { replace: true });
navigate.back();How it works
The trunk
Three signals read the browser once at import, then stay in sync via popstate and hashchange listeners (auto-attached). pathname and hash are public; the raw query string is private and exposed through derived signals.
flowchart TB
BROWSER["window.location + history"]
BROWSER -->|"popstate / hashchange / navigate()"| SYNC["syncSignals()"]
SYNC --> P["pathname (signal)"]
SYNC --> H["hash (signal)"]
SYNC --> RQ["rawQuery (private signal)"]
RQ --> Q["query = computed(URLSearchParams)"]
Q --> QP1["queryParam('sort')"]
Q --> QP2["queryParam('page')"]
P --> R1["route('/users/:id')"]
P --> R2["route('/posts/:slug')"]
QP1 -.->|"only on ?sort change"| E1["your effect"]
QP2 -.->|"only on ?page change"| E2["your effect"]
R1 -.->|"only on match change"| E3["your effect"]The equality gate
Every write goes through lite-signal's Object.is check. navigate('/x') while already on /x sets pathname to the same string — the write is dropped, nothing propagates. A popstate that changed only the hash flows to hash and stops; pathname and query consumers never see it.
For queryParam, the query computed does re-derive on every query-string change (it builds a fresh URLSearchParams), but each queryParam(key) is its own computed returning a string, so its Object.is check halts propagation unless that specific key's value moved.
Setup-time compilation
route('/users/:id') compiles the pattern to an anchored RegExp once, at call time — never on the navigation path. Literal characters are regex-escaped (so /files/:name.json treats .json literally), :params become ([^/]+) capture groups, and a trailing slash is optional. On the hot path it's one regex.exec plus, on a match, one params object.
The unique part: surgical updates
This is the property no broadcast router has. Set up N independent widgets, each reading a different slice of the URL, then change exactly one slice per navigation and count how many widget bodies actually re-run.
const widgets = ['sort', 'page', 'view', 'lang', 'theme', 'zoom', /* ...16 keys */];
let wakeups = 0;
for (const key of widgets) {
const p = queryParam(key);
effect(() => { p(); wakeups++; }); // each widget reads ONE key
}
// Flip one key per navigation:
navigate('/x?sort=desc&page=1&...'); // only the `sort` widget wakes
navigate('/x?sort=desc&page=2&...'); // only the `page` widget wakesnpm run bench runs exactly this with 16 widgets over 10,000 navigations:
| Router model | Downstream re-runs (10k navs · 16 widgets) | Per navigation | |---|---:|---:| | Naive (broadcast to all subscribers) | 160,000 | 16 | | lite-router (fine-grained) | 10,015 | ≈ 1 |
%%{init: {"theme":"dark"}}%%
xychart-beta
title "Downstream re-renders over 10k navigations — lower is better"
x-axis ["naive broadcast", "lite-router"]
y-axis "re-renders" 0 --> 170000
bar [160000, 10015]16× less downstream work, and it scales with your widget count: at 50 subscribers the ratio is ~50×. The cost of an irrelevant navigation is paid once (the trunk .set + equality check), not once per subscriber.
API reference
All reads are lite-signal functions: call them to get the value (and, inside an effect/computed, to subscribe). .peek() reads without subscribing.
Trunk signals
| Export | Type | Description |
|---|---|---|
| pathname | Signal<string> | window.location.pathname, kept in sync. Writable, but prefer navigate. |
| hash | Signal<string> | window.location.hash, including the leading #. In hash mode this is the inner fragment (a second # inside the route). |
| query | Computed<URLSearchParams> | Parsed query string. Re-derives only when the literal string changes. |
query()is aURLSearchParams, not a plain object — read it with.get(). It does not spread or serialize:query().get('sort') // 'asc' ✅ the right way { ...query() } // {} ❌ empty JSON.stringify(query()) // '{}' ❌ empty query().sort // undefined ❌Need a plain object (to spread, log, or serialize)? Use
queryObject(). Reading one key reactively? UsequeryParam(key).
queryParam(key) → Computed<string | null>
A memoized computed for a single query key. Calling with the same key returns the same node (no graph explosion across views). Returns the value or null if absent. Propagation stops here unless this key changed. Keys must be statically enumerable — each distinct key mints one cached node for the module's life (an unbounded set of dynamic keys fails closed with a CapacityError rather than leaking).
queryParamAll(key) → Computed<string[]>
The multi-valued sibling of queryParam, for repeated keys (?tag=a&tag=b → ['a', 'b']). Memoized per key like queryParam, and returns a shared frozen [] when the key is absent. A custom length + element-wise equality keeps it surgical: a change to an unrelated key does not wake it, and two navigations that leave this key's values untouched propagate nothing (a fresh getAll() array would otherwise defeat the Object.is gate and re-run every subscriber on every navigation). Value order is significant — ?tag=a&tag=b and ?tag=b&tag=a are different values. Same static-enumerability contract as queryParam.
queryObject() → Record<string, string>
A plain-object snapshot of the current query string, e.g. { sort: 'asc' } — the escape hatch for query()'s URLSearchParams. Allocates a fresh object per call and collapses duplicate keys to their last value. Use queryParam(key) on hot paths and queryParamAll(key) for multi-valued keys.
route(pattern) → Computed<Record<string,string> | null>
Compiles pattern to a matcher signal. Returns a params object on match, null otherwise.
| Pattern | Matches | route() returns |
|---|---|---|
| /about | /about, /about/ | {} (shared frozen object) |
| /users/:id | /users/42 | { id: '42' } |
| /users/:id/posts/:postId | /users/7/posts/9 | { id: '7', postId: '9' } |
| /files/:name.json | /files/report.json | { name: 'report' } |
| * | anything | {} |
Params are decodeURIComponent-decoded; a malformed escape falls back to the raw segment rather than throwing. When pattern is a string literal, the params object type is inferred from it (route('/users/:id') → Computed<{ id: string } | null>) — zero runtime cost, pure .d.ts.
navigate(to, options?)
| Arg | Type | Description |
|---|---|---|
| to | string | Target path/URL, e.g. /users/42?tab=bio. |
| options.replace | boolean | Replace the current history entry instead of pushing. |
Navigating to the URL the router already shows is a no-op — no duplicate history entry, no graph work. Fails closed (no-op) with no window. Also: navigate.back() and navigate.forward().
setQuery(patch, options?)
Surgically write query parameters, preserving every key not named in patch. A null/undefined value deletes its key; everything else is stringified. One URLSearchParams build and one navigation per call — the write-side mirror of queryParam.
// at /list?sort=asc&page=1
setQuery({ page: 2 }); // -> /list?sort=asc&page=2 (sort preserved)
setQuery({ sort: null }); // -> /list?page=2 (sort deleted)
setQuery({ view: 'grid' }, { replace: true });beforeNavigate(fn) → () => void
Install a single synchronous navigation guard (or clear it with null). It runs on the programmatic-navigation path only — navigate, setQuery, and interceptLinks — before the URL changes:
const off = beforeNavigate((to) => {
if (formDirty && to !== '/editor') return false; // cancel
if (!loggedIn && to.startsWith('/admin')) return '/login'; // redirect (replace)
// return undefined -> proceed
});
// ...later:
off(); // teardown clears the guard if it's still current
beforeNavigate(null); // or clear explicitly- Return
falseto cancel (no history entry, no graph work). - Return a string to redirect to it (via
replace, so the cancelled target leaves no entry). Redirecting to the same target you're navigating to simply proceeds — the common "send everything to/loginexcept/login" pattern terminates; a genuine ping-pong (/a → /b → /a) fails closed with a redirect-loop error. - Return anything else (
undefined) to proceed.
This is deliberately one slot, not a middleware pipeline — it covers unsaved-changes prompts and auth redirects without inventing ordering semantics. Browser Back/Forward (popstate/hashchange) is observed, never cancelled: the URL has already changed by the time the event fires, so the router syncs to it. A guard that throws aborts the navigation and propagates the error, leaving the router fully usable.
configure(options?)
Startup-time configuration. Must be called before the first navigate() — the router locks on first navigation and a later configure() throws.
| Option | Type | Description |
|---|---|---|
| mode | 'history' \| 'hash' | 'history' (default) or 'hash'. In hash mode the whole route + query surface lives after #. See Hash mode. |
| basePath | string | A URL prefix (e.g. /app) stripped from pathname on read and prepended by navigate on write. For GitHub Pages / iframe sub-path hosting. navigate('/x') under base /app writes /app/x and the pathname signal reads /x. History mode only (inert in hash mode, where the base lives before the #). |
| url | string | Seed the full URL with no window (SSR/tests). Throws in the browser, where window.location is the source of truth. In hash mode the fragment (after the first #) is taken as the app URL. |
Hash mode
configure({ mode: 'hash' }) moves the whole route + query surface after the # — #/users/42?sort=desc — so routing works from file://, static hosting with no rewrite rules, and sandboxed iframes. The public signal API is identical; only the sync layer differs (hashchange-driven, no history rewrites), so the entire test suite runs unchanged in both modes.
The one place the two modes' surface diverges in meaning is the hash trunk signal. In hash mode the app URL already lives in the fragment, so hash becomes the fragment-within-the-fragment — a second # inside the route:
#/users/42?sort=desc#section
└─ pathname ─┘└ query ┘└ hash ┘
/users/42 ?sort=desc #sectionPlain <a href="#/users/42"> anchors also work natively in hash mode (the browser updates the fragment and hashchange syncs the graph) — but those bypass beforeNavigate, which is a programmatic-path hook. Route such links through navigate() if you need the guard. (interceptLinks is the history-mode counterpart.)
@zakkster/lite-router/testing
import { _resetRouter } from '@zakkster/lite-router/testing' — a test-isolation helper (clears the queryParam cache and all configuration, resets the trunk signals, re-binds listeners). Kept off the main barrel so it never reaches an app bundle.
interceptLinks(root?) → () => void
Delegates one click listener on root (default document.body) and routes same-origin <a> clicks through navigate. Returns a teardown function. Left to the browser: modified clicks (Ctrl/Meta/Shift/Alt), non-left buttons, already-defaultPrevented events, cross-origin links, target="_blank", download, and mailto:/tel:.
Recipes
Patterns people actually build with. Each rests only on the public API above.
Route-driven views
A route is just a computed, so a view swap is an effect that reads it — and because each route() and queryParam() is its own node, unrelated URL changes don't re-run the swap. Pair it with @zakkster/lite-signal-dom for the DOM binding, or drive any renderer from the same signals:
import { route, pathname } from '@zakkster/lite-router';
import { effect } from '@zakkster/lite-signal';
const home = route('/');
const user = route('/users/:id');
const outlet = document.getElementById('outlet');
effect(() => {
const u = user(); // re-runs only when the /users/:id match changes
if (u) return void renderUser(outlet, u.id);
if (home()) return void renderHome(outlet);
renderNotFound(outlet);
});
// Nav highlighting is a per-link computed — the active link wakes, the rest sleep.
for (const link of document.querySelectorAll('nav a')) {
const href = link.getAttribute('href');
effect(() => link.classList.toggle('active', pathname() === href));
}Lazy view loading (code-splitting)
Load a heavy view's module only when its route first matches, using a dynamic import() inside the route effect. Guard the async gap so a fast Back/Forward can't mount a stale view:
import { route } from '@zakkster/lite-router';
import { effect } from '@zakkster/lite-signal';
const editor = route('/editor/:doc');
let token = 0;
effect(() => {
const m = editor();
if (!m) return;
const mine = ++token; // claim this navigation
import('./views/editor.js').then((mod) => {
if (mine === token) mod.mount(outlet, m.doc); // still the current route? mount.
});
});Auth gates and unsaved-changes prompts
beforeNavigate is one synchronous slot on the programmatic path. Return false to cancel, a string to redirect, undefined to proceed. A same-target redirect proceeds, so the "send everything to /login" pattern terminates on its own:
import { beforeNavigate, navigate } from '@zakkster/lite-router';
// Auth gate: bounce protected routes to /login until authenticated.
beforeNavigate((to) => {
if (to.startsWith('/admin') && !isLoggedIn()) return '/login';
});
// Unsaved-changes guard: confirm before leaving a dirty editor.
const off = beforeNavigate((to) =>
editorIsDirty() && !confirm('Discard unsaved changes?') ? false : undefined
);
// off() removes the guard when the editor unmounts.Guards see only programmatic navigation; Back/Forward has already changed the URL by the time it fires, so it is observed, never cancelled. Keep it to this one slot — the moment a guard chain needs ordering, it wants a state machine, not a router.
Benchmarks
node --expose-gc bench/bench.js # or: npm run benchRuns four measurements under bench protocol v3 (machine-stamped provenance; throughput as the median of 7 repetitions with the inter-quartile spread), prints a table, and writes bench/bench-results.json. --expose-gc is required for the heap numbers.
Measured on Apple M4 Pro, Node 26 (darwin/arm64), median of 7 reps. Absolute throughput varies by machine and is stamped into the JSON artifact; the surgical ratio is machine-independent. (Protocol v3's cross-machine publish gate wants a second machine appended to machines[] — a data-collection step, pending.)
| Measurement | Result |
|---|---:|
| Match /users/:id/posts/:postId (2 params) | ~1,500,000 navigations/sec (IQR 1.2%) |
| Match static /about (alternating hit/miss) | ~2,770,000 navigations/sec (IQR 1.3%) |
| Retained heap growth per navigation | ≈ 0 B (−5 KB over 500k navs — i.e. noise) |
| Downstream re-renders vs naive (16 widgets) | 16× fewer |
Surgical scaling curve — one param flipped per navigation, K independent widgets each reading a different key. lite-router wakes ~one widget per navigation regardless of K; a naive router wakes all of them, so the advantage tracks the widget count:
| Widgets K | 1 | 8 | 16 | 50 | 100 |
|---|---:|---:|---:|---:|---:|
| Re-runs vs naive | 1.0× | 8.0× | 16.0× | 49.8× | 99.0× |
%%{init: {"theme":"dark"}}%%
xychart-beta
title "Surgical advantage scales with widget count (x fewer re-runs)"
x-axis ["1", "8", "16", "50", "100"]
y-axis "x fewer re-runs" 0 --> 100
bar [1, 8, 16, 49.8, 99]Why "≈ 0 bytes per navigation"
Signal propagation in lite-signal is allocation-free in steady state (pooled nodes and links). The only per-navigation allocations lite-router itself makes are short-lived: the regex.exec match array and the params object on a match. They die immediately and never accumulate — across 500,000 navigations the retained heap does not grow. (If you want literally zero per-nav allocation, match against pathname() yourself and skip the params object; for virtually every app the difference is unmeasurable.)
Testing (for clients & QA)
Two levels of verification.
1. Unit tests — "does it do what it says?"
npm test # full suite, no flags needed (the GC test self-skips)
npm run test:gc # everything including the zero-allocation guaranteeA clean npm run test:gc ends with pass 125, fail 0. Suitable for CI. Coverage:
| Group | What's tested |
|---|---|
| Trunk + navigation | push/replace sync, popstate, hashchange, Object.is dedup of no-op navigations |
| Query params | parsing, memoization (same key → same node), null for absent keys |
| Surgical updates | changing ?page does not re-run a ?sort consumer; the consumer wakes exactly once when ?sort moves |
| Route matching | single/multi params, URL-decoding, malformed-escape fallback, trailing slash, catch-all, slash boundaries |
| Regex escaping | a literal . is not a wildcard; literal suffix around a :param |
| Fuzz / torture | seeded pattern & percent-encoding fuzz vs a naive reference matcher, and an interleaved navigate/popstate/hashchange event storm — run in both history and hash mode |
| Both sync modes | the whole public-API battery runs in history and hash mode (parity by contract) |
| Hash mode | fragment-within-fragment (#/a?b=c#section) semantics; hashchange sync; basePath inert |
| beforeNavigate | cancel / redirect / proceed; same-target collapse; ping-pong loop fails closed; a throwing guard doesn't wedge; popstate observed not cancelled |
| queryParamAll | repeated-key values, stable frozen [], order significance, surgical wake, oracle vs getAll |
| Link interception | internal hijack, and bypass of modifiers / external origin / _blank / download / mailto:/tel: / SVG anchors / pre-prevented events; teardown removes the listener |
| Graph hygiene | node count is flat across 50,000 navigations (no leak); queryParam cache doesn't grow on key reuse |
| Zero-allocation | retained heap grows < 256 KB over 200,000 navigations (requires --expose-gc) |
2. Benchmark — "does it perform as claimed?"
npm run benchReproduces the throughput, allocation, and surgical-update numbers above on your own hardware.
Quick npm run reference
| Command | What it does |
|---|---|
| npm test | The test suite, including fuzz/torture cases (GC test self-skips without the flag) |
| npm run test:gc | Full suite including the zero-allocation guarantee |
| npm run bench | Throughput + allocation + surgical-update benchmark |
| npm run verify | test:gc && bench — the full CI-style check |
| npm run demo | Prints the path to the interactive demo |
Running the demo
example/demo.htmlDouble-click it — no build step, no server, no install. It runs on real @zakkster/lite-router and @zakkster/lite-signal (an import map resolves both to the repo's own files), and it runs in hash mode, which is exactly what lets a router work from file:// with no server rewriting paths.
It's a small, honest tour of the actual API:
| Route | Demonstrates |
|---|---|
| #/users/:id?sort=… | a route param + a single queryParam; the sort buttons are navigate() calls |
| #/gallery?tag=a&tag=b | queryParamAll('tag') — repeated keys as a reactive string[] |
| #/editor | a beforeNavigate guard: check "dirty" and it cancels navigation away (with a live log) |
Every readout is a lite-signal effect bound to one slice of the URL, so only the affected pieces update on each navigation.
Browser & engine compatibility
The library uses only history, location, URL, URLSearchParams, and standard events — everywhere ES2015+ ESM runs.
| Target | Library |
|---|---|
| Chrome / Edge 61+ | ✅ |
| Firefox 60+ | ✅ |
| Safari 11+ (iOS 11+) | ✅ |
| Node.js 18+ | ✅ (imports safely; no-ops without a window) |
| Bun / Deno | ✅ |
The standalone
example/demo.htmluses an import map (Chrome/Edge 89+, Firefox 108+, Safari 16.4+); any 2023+ browser runs it directly from disk.
Edge cases & guarantees
Behaviours the test suite pins down:
- No-op navigations are free.
navigate('/x')while on/xpropagates nothing — theObject.isgate drops the write before any consumer sees it. - A hash-only change touches only
hash.pathnameandqueryconsumers don't fire on#sectionnavigation. queryParam(key)is memoized. N calls with the same key share one graph node. The cache holds one node per distinct key; it doesn't grow on reuse.- Static routes return a stable frozen object.
route('/about')returns the sameObject.freeze({})on every match, so downstream effects don't churn when navigating between matching paths. - Regex metacharacters in patterns are literal.
/v1.0/xmatches/v1.0/x, not/v1x0/x. The:paramsyntax is the only special form. - Malformed percent-encoding never throws. A bad
%-escape in a param falls back to the raw matched segment. - Params don't cross slashes.
:idmatches[^/]+;/a/:iddoes not match/a/b/c. - SSR / no-DOM safe. Importing under Node seeds
'/'/''/''and skips listener attachment; nothing throws. - The graph doesn't leak. Across 50,000 navigations the active node count is exactly what it started — no accumulation.
- Both modes pass the same suite. The public-API battery runs in
historyandhashmode; only the sync layer differs. beforeNavigateis one synchronous slot. Cancel/redirect/proceed on the programmatic path; Back/Forward is observed, not cancelled; a ping-pong redirect fails closed; a throwing guard doesn't wedge the router.queryParamAllstays surgical. Its length+element equality means an unrelated key change — or a re-navigation with the same values — wakes no subscriber, despitegetAll()returning a fresh array each time.
FAQ
How is this different from a hash router or history wrapper?
Those give you one signal/event for "the URL changed". lite-router gives you a graph — per-route and per-param signals — so consumers subscribe narrowly and the equality gate suppresses irrelevant updates. The surgical-updates section is the concrete difference. (lite-router can run as a hash router — configure({ mode: 'hash' }) — but the fine-grained graph is the same in both modes.)
What about route guards / async data?
For a synchronous cancel-or-redirect (unsaved changes, auth), use beforeNavigate. For async, use lite-signal's watch / whenAsync against the route signals and redirect via navigate(..., { replace: true }).
Do I need a rendering framework?
No. Anything that can run a callback works — call mount/render/patch inside an effect. It pairs naturally with lite-signal-driven UIs, but it's framework-agnostic.
Nested routes?
Compose them. route('/users/:id') and route('/users/:id/posts/:postId') are independent signals; layout logic is just if (child()) … else if (parent()) … inside one effect. There's no built-in outlet system — by design.
Why is the query string private but pathname public?
You almost never want to react to the raw ?a=b&c=d string — you want a specific key. queryParam(key) gives you that with per-key equality. query is exposed for the rare full-parse case.
Does navigate allocate?
No default options object is created (the flag is read directly). The history call and syncSignals are allocation-free; only a matched route() builds a params object, and only when the match changes.
Is it really sub-2KB? The four source files minify-and-gzip to roughly that. Check the live bundle size badge for the current number.
License
MIT © Zahary Shinikchiev
