@zakkster/lite-router
v1.0.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.
Downloads
115
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
- 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-routerESM-only. One peer-ish dependency: @zakkster/lite-signal (the reactive core).
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 #. |
| query | Computed<URLSearchParams> | Parsed query string. Re-derives only when the literal string changes. |
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.
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.
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. |
Also: navigate.back() and navigate.forward().
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:.
Benchmarks
node --expose-gc bench/bench.js # or: npm run benchRuns three measurements, prints a table, and writes bench/bench-results.json. --expose-gc is required for the heap numbers.
Measured on Node 22 (linux/x64), one run. Absolute throughput varies by machine; the ratios don't.
| Measurement | Result |
|---|---:|
| Match /users/:id/posts/:postId (2 params) | ~507,000 navigations/sec |
| Match static /about (alternating hit/miss) | ~855,000 navigations/sec |
| Retained heap growth per navigation | ≈ 0 B (−8 KB over 500k navs — i.e. noise) |
| Downstream re-renders vs naive (16 widgets) | 16× fewer |
%%{init: {"theme":"dark"}}%%
xychart-beta
title "Match throughput (thousand navigations/sec) — higher is better"
x-axis ["2-param route", "static route"]
y-axis "k navs/sec" 0 --> 900
bar [507, 855]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 # 37 assertions, no flags needed (the GC test self-skips)
npm run test:gc # all 37 including the zero-allocation guaranteeA clean run ends with pass 37, 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 |
| 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 37-test suite (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's a single self-contained file. Inside it, the actual route() / queryParam() / navigate() logic runs on a compact reactive core with the same Object.is-gated propagation as lite-signal.
The demo is a routing console: the URL sits at the top as a color-coded "bus", and below it are independent consumer cards, each a reactive effect bound to one slice (a route param, a single query key, the pathname). Change part of the URL and only the cards consuming that slice flash and tick their render counter — everything else stays still. A live scoreboard tallies lite-router's re-renders against a naive router that would re-render every consumer on every navigation, and shows the multiplier climbing.
| Control | Action |
|---|---|
| Preset buttons | Jump to routes / flip one query param |
| ⚡ flip one random param ×25 | Rapid-fire single-param changes — watch the scoreboard ratio climb |
| URL input + go → | Navigate anywhere by hand |
| reset counters | Zero the scoreboard |
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.htmladditionally uses CSSmask-imageand modern fonts; any 2021+ browser renders it fully.
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.
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.
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.
What about route guards / async data?
Use lite-signal's watch / whenAsync against the route signals: whenAsync(() => user()) resolves when you land on the route. Redirect by calling navigate(..., { replace: true }) from an effect.
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
