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

Readme

@zakkster/lite-router

npm version 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

ESM-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 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 #. | | 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 bench

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

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

Double-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.html additionally uses CSS mask-image and 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 /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.

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