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

@purityjs/core

v0.4.0

Published

Minimal web framework with TC39-Signals-inspired reactivity, no virtual DOM

Readme

@purityjs/core

npm version npm downloads bundle size license

The core Purity framework. Signal-driven rendering without a virtual DOM.

Use the feature entry points described in the 0.4 import guide. Production sizes depend on the APIs used; run npm run check:bundle for current measurements.

Install

npm install @purityjs/core

API

Reactive Primitives

import { state, compute, watch, batch } from '@purityjs/core';

const count = state(0);
count(); // read → 0
count(5); // write → 5
count((v) => v + 1); // update → 6
count.peek(); // read without tracking

const doubled = compute(() => count() * 2);

watch(() => console.log(count())); // auto-track, runs immediately
watch(count, (val, old) => {}); // explicit, skips initial
watch([a, b], ([va, vb], [oa, ob]) => {}); // multi-source

batch(() => {
  a(1);
  b(2);
}); // single flush

Async Resources

Race-safe async data, built on signals. Each resource() exposes reactive data, loading, and error accessors and auto-aborts stale requests via a fresh AbortSignal whenever its dependencies change or its component unmounts. The fetcher receives an AbortSignal that fires on dep change or unmount, so callers don't need to wire AbortController themselves.

import { state, resource } from '@purityjs/core'; import { html } from '@purityjs/core/jit';

const userId = state(1);

const user = resource(
  () => userId(), // re-fetch when this changes
  (id, { signal }) => fetch(`/u/${id}`, { signal }).then((r) => r.json()),
  { initialValue: null },
);

html`
  ${() => (user.loading() ? html`<p>Loading…</p>` : null)}
  ${() => (user.error() ? html`<p>${`Error: ${String(user.error())}`}</p>` : null)}
  ${() => user()?.name}

  <button @click=${() => user.refresh()}>Refresh</button>
`;

| API | Effect | | ----------------- | --------------------------------------------------------------------- | | r() / r.get() | Current data (tracked). undefined until the first fetch resolves. | | r.loading() | true while a fetch is in flight (tracked). | | r.error() | The most recent rejection, or undefined (tracked). | | r.refresh() | Re-runs the fetcher with the current deps. | | r.mutate(v) | Optimistically writes data and clears any error. | | r.dispose() | Aborts any in-flight request and tears down the watcher. | | Falsy source | Returning null / undefined / false from the source skips fetch. | | AbortSignal | Aborted automatically on dep change or unmount. | | SWR by default | r() keeps the last successful value during refetch — no flash. |

Single-arg form (auto-tracked deps inside the fetcher) is also supported:

const todos = resource(({ signal }) =>
  fetch(`/todos?limit=${limit()}`, { signal }).then((r) => r.json()),
);

Options

resource(source, fetcher, {
  initialValue: [], // seed before first fetch
  retry: 3, // exponential backoff
  retry: { count: 5, delay: (a) => 1000 * 2 ** a }, // custom backoff
  pollInterval: 30_000, // auto-refresh every N ms
});

Lazy / imperative — lazyResource

For mutations, button-triggered loads, and form submissions. Same accessor shape as resource(), but does not run on creation. Call r.fetch(args) to trigger; r.refresh() re-runs with the most recent args.

import { lazyResource } from '@purityjs/core';

const save = lazyResource((data: { name: string }, { signal }) =>
  fetch('/save', { method: 'POST', body: JSON.stringify(data), signal }),
);

html`
  <button @click=${() => save.fetch({ name: 'Jane' })}>Save</button>
  ${() => (save.loading() ? html`<p>Saving…</p>` : null)}
  ${() => (save.error() ? html`<p>Failed</p>` : null)}
`;

Debounced source — debounced

A read-only derived signal that mirrors a source after ms of quiet. Useful for search-as-you-type and other rate-limited inputs.

import { state, debounced, resource } from '@purityjs/core';

const search = state('');
const query = debounced(search, 300);

const results = resource(
  () => query() || null,
  (q, { signal }) => fetch(`/search?q=${q}`, { signal }).then((r) => r.json()),
);

html`<input ::value=${search} placeholder="search…" />`;

End-to-end recipes

Pagination with SWR — r() keeps the previous page's data visible while the next page is fetching, so the list never flashes empty.

const page = state(1);
const items = resource(
  () => page(),
  (p, { signal }) => fetch(`/items?page=${p}`, { signal }).then((r) => r.json()),
);

component('p-paginated', () => {
  return html`
    <ul>
      ${each(
        () => items() ?? [],
        (item) => html`<li>${() => item().name}</li>`,
      )}
    </ul>
    <button @click=${() => page((p) => Math.max(1, p - 1))}>Prev</button>
    <span>${() => page()}</span>
    <button @click=${() => page((p) => p + 1)}>Next</button>
    ${() => (items.loading() ? html`<small>updating…</small>` : null)}
  `;
});

Search-as-you-type — debounce the input, skip fetches on empty strings, and let the resource's AbortSignal cancel the in-flight HTTP request when a newer keystroke arrives.

component('p-search', () => {
  const term = state('');
  const query = debounced(term, 300);
  const results = resource(
    () => query().trim() || null,
    (q, { signal }) =>
      fetch(`/search?q=${encodeURIComponent(q)}`, { signal }).then((r) => r.json()),
  );

  return html`
    <input ::value=${term} placeholder="search…" />
    ${() => (results.loading() ? html`<p>searching…</p>` : null)}
    <ul>
      ${each(
        () => results() ?? [],
        (hit) => html`<li>${() => hit().title}</li>`,
      )}
    </ul>
  `;
});

Optimistic save with rollback — mutate the local data immediately, then call the lazy save resource. If the save errors, restore the previous value.

component('p-todo-row', ({ todo }) => {
  const local = state(todo);
  const save = lazyResource((next: typeof todo, { signal }) =>
    fetch(`/todos/${next.id}`, {
      method: 'PUT',
      body: JSON.stringify(next),
      signal,
    }).then((r) => {
      if (!r.ok) throw new Error('save failed');
      return r.json();
    }),
  );

  const toggle = () => {
    const prev = local.peek();
    const next = { ...prev, done: !prev.done };
    local(next); // optimistic
    save.fetch(next);
    watch(save.error, (err) => {
      if (err) local(prev); // roll back
    });
  };

  return html`
    <label>
      <input type="checkbox" ?checked=${() => local().done} @change=${toggle} />
      ${() => local().title} ${() => (save.loading() ? html`<small>saving…</small>` : null)}
    </label>
  `;
});

Polling dashboard with pause — pollInterval re-fetches every N ms after each settle. Toggle pausing by returning null from the source (see the example): the falsy-source path stops the polling without tearing the resource down. (r.dispose() is terminal — for permanent teardown, not pause/resume.)

component('p-dashboard', () => {
  const paused = state(false);
  const stats = resource(
    () => (paused() ? null : 'tick'),
    (_, { signal }) => fetch('/stats', { signal }).then((r) => r.json()),
    { pollInterval: 5_000, retry: 2 },
  );
  return html`
    <h2>Live stats</h2>
    <pre>${() => JSON.stringify(stats(), null, 2)}</pre>
    <button @click=${() => paused((v) => !v)}>${() => (paused() ? 'Resume' : 'Pause')}</button>
    ${() => (stats.error() ? html`<p>connection lost</p>` : null)}
  `;
});

Performance

Numbers from the package's vitest bench (npm run bench -w packages/core) on a 2024 M-class laptop, jsdom environment. Construction and disposal happen in setup/teardown so the timed region is the steady-state operation. Treat as relative — useful for tracking regressions, not for cross-framework claims.

| Operation | ops/sec | per op | | -------------------------------------------------- | ------- | ------- | | resource() construct + sync resolve + dispose | ~55 k | ~18 µs | | resource() construct + async resolve + dispose | ~64 k | ~16 µs | | resource(source) construct, source-skip, dispose | ~377 k | ~3 µs | | 1 dep change → fetch → resolve | ~113 k | ~9 µs | | 10 rapid dep changes → 1 winning resolve | ~113 k | ~9 µs | | 100 reactive watchers on a resolved r | ~24 k | ~42 µs | | mutate(value) (steady-state) | ~6.2 M | ~160 ns | | refresh() round-trip | ~113 k | ~9 µs | | lazyResource() construct (no fetch) + dispose | ~234 k | ~4 µs | | lazyResource.fetch(args) → resolve | ~100 k | ~10 µs | | debounced() construct + dispose | ~784 k | ~1.3 µs | | debounced 1 source update | ~740 k | ~1.4 µs | | debounced 100 rapid updates (coalesced) | ~378 k | ~2.6 µs |

The "10 rapid dep changes" row matches "1 dep change" — the manual prev-key dedup collapses bursts to a single fetch. mutate(value) in steady state is essentially runId++ plus a no-op batch() because writeState short-circuits on Object.is.

Memory (per resource lifecycle, after dispose) — measured with benchmark/tools/resource-heap.mjs over 1 000 cycles:

| Cycle | Retained per cycle | | ------------------------------------------------ | ------------------ | | resource() sync resolve + dispose | ~80 B | | resource() async resolve + dispose | ~85 B | | resource(source, fetcher) full fetch + dispose | ~265 B | | lazyResource() construct + fetch + dispose | ~120 B | | debounced() construct + 10 updates + dispose | ~30 B |

These are V8 bookkeeping noise — sub-kilobyte per cycle indicates the lifecycle is a closed loop and the controllers, watchers, and state nodes are all reclaimed on dispose.

Run yourself:

npm run bench -w packages/core             # vitest micro-benches
cd benchmark && node --expose-gc \
  --conditions=development tools/resource-heap.ts   # heap diff per cycle

Templates

JIT compiled: html tagged literals → parse → AST → codegen → cached DOM factory.

import { html } from '@purityjs/core/jit';

html`
  <div class=${() => (active() ? 'on' : 'off')}>
    <p>${() => count()}</p>
    <input ::value=${text} />
    <button @click=${save} ?disabled=${() => !valid()}>Save</button>
    <ul>
      ${each(
        () => items(),
        (item) => html`<li>${() => item()}</li>`,
      )}
    </ul>
  </div>
`;

| Syntax | Meaning | | ------------------- | ----------------------------------------- | | ${() => signal()} | Reactive text | | @event=${fn} | Event listener | | :prop=${val} | One-way prop binding | | ::prop=${signal} | Two-way binding (input, checkbox, select) | | ?attr=${bool} | Boolean attribute | | .prop=${val} | DOM property |

Components

Custom Elements with Shadow DOM. Registered globally by tag name.

import { component, slot, onMount, onDestroy, onDispose } from '@purityjs/core';

component('p-card', ({ title }, { default: body, header }) => {
  css`
    .card {
      padding: 1rem;
    }
  `;

  onMount(() => console.log('ready'));
  onDestroy(() => console.log('gone'));

  return html`
    <div class="card">
      <h2>${title}</h2>
      ${header()} ${body()}
    </div>
  `;
});

Scoped slots — expose data from component to consumer:

component('p-form', (_props, { default: body }) => {
  const isValid = compute(() => true);
  return {
    view: html`<form>${body({ validate: isValid })}</form>`,
    expose: { validate: isValid },
  };
});

// Consumer
Form({}, ({ validate }) => html` <button ?disabled=${() => !validate()}>Save</button> `);

Trade-offs of Custom Elements + Shadow DOM by default

component() always registers a Custom Element with Shadow DOM. This is an opinionated choice — listing the costs honestly so you can decide:

  • Global CSS / utility frameworks (Tailwind, CSS reset, design tokens). Styles applied to document do not pierce a shadow root. Per-component utility CSS works via adoptedStyleSheets — Tailwind needs explicit injection per shadow root. CSS variables do pierce, so design tokens declared on :root work normally.
  • Form libraries / native form participation. For a component wrapping one input, select, or textarea, use { formControl: true }. Purity then publishes its value and validity through ElementInternals. Libraries that only walk light DOM still cannot inspect the internal control.
  • Accessibility across shadow boundaries. aria-labelledby / aria-describedby cannot reference IDs across a shadow root. Use aria-label directly or expose explicit ARIA attributes on the host. See docs/accessibility.md for the browser checks and remaining manual acceptance criteria.
  • Third-party DOM queries. document.querySelector('.my-class') from outside a component will not find elements inside its shadow root. Inspect via the host element first.
  • SSR. The separate @purityjs/ssr package serializes components with Declarative Shadow DOM.
  • Platform name collisions. Custom-element tag names must contain a hyphen, but anything starting with font-, annotation-xml, etc. is reserved. The convention p-yourname keeps you clear.

If these costs outweigh the encapsulation benefit for your app, Purity may not be the right fit — most of the framework's other primitives (state, compute, watch, resource, debounced) work fine without component() if you want to render to plain elements via mount() and html only.

Scoped Styles

Shadow DOM handles scoping inside components. Reactive values auto-update.

css`
  .title {
    color: red;
  }
`;
css`
  .box {
    background: ${() => (dark() ? '#333' : '#fff')};
  }
`;

Control Flow

import { match, when, each } from '@purityjs/core';

when(
  () => ok(),
  () => html`<p>Yes</p>`,
  () => html`<p>No</p>`,
);

match(() => status(), {
  loading: () => html`<p>Loading...</p>`,
  error: () => html`<p>Error</p>`,
  success: () => html`<p>Done</p>`,
});

each(
  () => items(),
  (item) => html`<li>${() => item().name}</li>`,
  (item) => item.id,
);

For a long list, add { virtual: true } and put it in a scrollable container:

html`<ul style="max-height: 24rem; overflow-y: auto">
  ${each(
    () => items(),
    (item) => html`<li>${() => item().name}</li>`,
    (item) => item.id,
    { virtual: true },
  )}
</ul>`;

Purity manages the visible range, spacers, and row-height measurements. Supply stable keys when items can be inserted, removed, or reordered. SSR still renders the full list; hydration adopts those rows before windowing them.

Teleport

import { teleport } from '@purityjs/core';

teleport('#modal-root', () => (visible() ? html`<div class="modal">Open</div>` : null));

Lifecycle

onMount(fn); // after DOM insertion (microtask)
onDestroy(fn); // on unmount
onDispose(fn); // register cleanup (auto-called on unmount)
onError(fn); // error boundary

Mount

import { mount } from '@purityjs/core';

const { unmount } = mount(() => html`<p-app></p-app>`, document.getElementById('app')!);

Further reading

  • Live demo — polling dashboard with resource (retry + pollInterval), lazyResource, debounced, mock-data backend. Source: examples/dashboard.
  • TypeScript guide — inference patterns for state / compute / resource, generic component props, scoped slots, custom WatchSource-based helpers
  • Why Shadow DOM by default — when the encapsulation pays, when it hurts, escape hatches, Tailwind / global-CSS integration
  • Accessibility under Shadow DOM — ARIA across boundaries, focus delegation, screen-reader patterns, a worked p-tabs example
  • Migration cheatsheet — side-by-side translations from React / SolidJS / Vue / Svelte to Purity

License

MIT