npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@zakkster/lite-router

npm version sponsor npm bundle size npm downloads npm total downloads lite-signal peer TypeScript License: MIT

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 programmatically

Measured 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

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"]
    end

It'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-null signal. Compose your own layout logic from those — it's just boolean signal math.
  • Not a server router. Client-side history API only. (It imports cleanly under Node/SSR — it just no-ops without a window.)

Install

npm i @zakkster/lite-router @zakkster/lite-signal

ESM-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 wakes

npm 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 a URLSearchParams, 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? Use queryParam(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 interceptLinksbefore 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 false to 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 /login except /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  #section

Plain <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 bench

Runs 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 guarantee

A 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 bench

Reproduces 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.html

Double-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.html uses 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 /x propagates nothing — the Object.is gate drops the write before any consumer sees it.
  • A hash-only change touches only hash. pathname and query consumers don't fire on #section navigation.
  • 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 same Object.freeze({}) on every match, so downstream effects don't churn when navigating between matching paths.
  • Regex metacharacters in patterns are literal. /v1.0/x matches /v1.0/x, not /v1x0/x. The :param syntax 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. :id matches [^/]+; /a/:id does 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 history and hash mode; only the sync layer differs.
  • beforeNavigate is 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.
  • queryParamAll stays surgical. Its length+element equality means an unrelated key change — or a re-navigation with the same values — wakes no subscriber, despite getAll() 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