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

@sigx/runtime-core

v1.0.1

Published

Runtime core for SignalX

Readme

@sigx/runtime-core

Runtime core for SignalX. Provides the platform-agnostic component system, JSX runtime, reconciler, lifecycle hooks, dependency injection, and control flow primitives.

📚 Full guides, API reference and live examples → https://sigx.dev/core/packages/runtime-core/overview/

Install

npm install @sigx/runtime-core

Usage

import { component, signal, onMounted, onUnmounted } from '@sigx/runtime-core';

const Timer = component(() => {
  const elapsed = signal(0);
  let interval: number;

  onMounted(() => {
    interval = setInterval(() => elapsed.value++, 1000);
  });

  onUnmounted(() => {
    clearInterval(interval);
  });

  return () => <span>Elapsed: {elapsed.value}s</span>;
});

Rest props — forwarding what you didn't consume

Forwarding a component's leftover props onto its root element is plain JS. Destructure what the component consumes; the rest is what the consumer passed:

const Button = component<ButtonProps>(ctx => () => {
  const { variant, ...rest } = ctx.props;
  return <button class={variant} {...rest} />;
});

The destructure is the declared-props list — there is no helper for it, and the rest object is typed by TypeScript off the props type. Framework-internal keys (key, ref, children, model bindings, client:* directives) never appear in ctx.props or reach the DOM, so the spread is safe.

For the component's props type to accept host attributes in the first place, declare & Define.Attrs:

type ButtonProps = Define.Prop<'variant', 'primary' | 'ghost'> & Define.Attrs;

Declare it only if the component really does forward — a type that compiles and then drops the attribute is the failure mode the opt-in exists to prevent.

mergeProps — when both sides set the same key

A JSX spread is flattened by the compiler into one object literal before the runtime sees it, so <button {...rest} {...bag} /> lets later keys clobber earlier ones. If the consumer and the component both set class, one is lost; same for onClick. mergeProps combines them instead:

const Button = component<ButtonProps>(ctx => {
  const merged = mergeProps(
    () => { const { variant: _v, ...rest } = ctx.props; return rest; },
    () => ({ class: 'btn', onClick: onActivate })
  );
  return () => <button {...merged}>{ctx.slots.default?.()}</button>;
});

| Key | Rule | |---|---| | class / className | concatenated in argument order, emitted as class | | style | merged into an object; string sources are parsed first | | on* handlers | chained in source order, grouped by the event they resolve to — onClick and onclick become one entry, so two handlers can never collide in the same DOM listener slot | | ref | chained; every source's ref is fed | | everything else | exact spread semantics — the last source with the key wins, including an explicit undefined |

Two things to know. mergeProps is not a defaults helper: it replaces a spread, so it behaves like one (destructuring with defaults already covers defaults). And call it once in setup, as above — the derived ref and chained handlers are identity-cached, and rebuilding them each render hands the renderer fresh functions that make it tear down and re-apply refs for nothing. Sources may be thunks, so the result stays reactive either way.

Chaining cannot express swallow. A component that gates a consumer handler — dropping onClick while disabled — keeps destructuring it out and calling it itself.

Vnode-valued props reach the renderer raw

A prop that holds an element — a fallback, an icon, an array of items — comes out of ctx.props as the very object jsx() created, not wrapped in the reactive props proxy: the renderer writes its bookkeeping onto vnodes and compares them by identity, and a proxied vnode would corrupt both. Replacing the prop still re-renders, because the read itself goes through the props signal.

The runtime recognises its own vnodes (those from jsx() / TSX, or from calling a component factory directly) by an internal brand, not by shape. A plain object of yours that happens to have type, props, children and dom keys — a CMS node, an editor AST — is user data and stays reactive. A vnode literal you assemble by hand is not a runtime vnode; create it through jsx() instead.

Setup reactions are disposed on unmount

effect(), watch(), and non-detached effectScope() created directly in a component's setup are tied to the component's lifetime — they're stopped automatically when it unmounts (and re-created on HMR reload). You don't need to hold their handles and call .stop() in onUnmounted:

const Search = component(() => {
  const query = signal('');
  // Auto-disposed on unmount — no manual cleanup needed.
  watch(() => query.value, (q) => runSearch(q));
  return () => <input onInput={(e) => (query.value = e.target.value)} />;
});

Only setup itself is scoped: reactions created inside onMounted/onCreated (or async callbacks) are not captured — dispose those via onUnmounted. computed() is lazy and needs no disposal.

Required injectables

defineInjectable(factory) gives an injectable a zero-config fallback: used without a provider, it lazily creates a module-global singleton. That is right for optional services, and wrong for per-app services like a router — on the server, a forgotten provide would silently share one instance across every request (dev builds warn when this happens during SSR).

Declare those services required by passing a name instead of a factory. There is no fallback; using it unprovided throws a structured error (SIGX202) naming the injectable. (In production builds, runtime errors carry the SIGX### code, any runtime detail, and a link to https://sigx.dev/errors/ — the full message and fix suggestion appear in dev builds; error.code is the same in both.)

export const useRouter = defineInjectable<Router>('Router');

// Per app (per request under SSR):
const app = defineApp(<App />);
app.defineProvide(useRouter, () => createRouter(url));

// In any component:
const router = useRouter();

App-level provides are read live: a defineProvide call made after app.mount() is visible to components mounted afterwards. Component-tree provides (defineProvide in setup) always take precedence.

That generated suggestion assumes defineProvide is the remedy. For a pack whose injectable is satisfied by rendering something, pass your own hint — it replaces the suggestion, keeping the SIGX202 code and the name:

export const useScreen = defineInjectable<Screen>('Screen', {
    hint: 'useIsFocused must be called from a component rendered as a route by <Stack>.',
});

The hint is only read in dev builds, but the string literal lives in your module, so it ships in your production bundle regardless — gate it yourself (hint: __DEV__ ? '…' : undefined) if the bytes matter.

The dev SSR warning on the factory form names the injectable after factory.name, which inline arrow factories don't have. Pass { name } to name it — otherwise the warning falls back to the definition site (defined at file:line), captured dev-only:

export const useSessionStore = defineInjectable(() => createSessionStore(), { name: 'sessionStore' });

The warning is skipped on live clients. That check is isLiveClient(), not typeof window, so windowless-but-live runtimes (lynx, terminal, Web Workers) stay quiet — see the declareLiveClient() note in the async docs.

Dependency injection outside components

Use-functions from defineInjectable/defineFactory resolve to app-context instances inside components. Code that runs outside component setup — router navigation guards, socket handlers, entry-scope code — must opt in with app.runWithContext(fn), or it silently gets a separate realm-level fallback instance:

const useAuthStore = defineFactory(() => createAuthStore(), 'scoped');
const app = defineApp(<App />);

router.beforeEach((to) => {
  // Same instance the app's components see — not a realm copy.
  const auth = app.runWithContext(() => useAuthStore());
  if (!auth.isAuthenticated && to.meta.requiresAuth) return '/login';
});

The context applies only to the synchronous portion of the callback — after an await, re-enter with another runWithContext call (dev builds warn once per app when the callback returns a Promise or other thenable):

// ❌ Wrong — after the await, useSession() resolves a realm fallback, not the app's instance
await app.runWithContext(async () => {
  const user = await fetchUser();
  useSession().user = user; // context already restored here
});

// ✅ Right — re-enter for each synchronous section that resolves dependencies
const user = await fetchUser();
app.runWithContext(() => {
  useSession().user = user;
});

Nested calls restore the previous context. Plugins receive the app in install() and can capture it to wrap their own callbacks.

Libraries that run user-authored callbacks in app context (routers invoking navigation guards, schedulers running handlers) can tailor that warning with the asyncAdvice option, so the advice reaches the callback's author instead of pointing at a runWithContext call site they never see:

// A string replaces the remediation sentence of the warning…
app.runWithContext(() => userGuard(to, from), {
  asyncAdvice: '(from my-router) Resolve injectables at the top of the guard, before the first await.'
});

// …false suppresses it for a deliberately sync-only call (the once-per-app
// warning slot is not consumed — later unmarked async callbacks still warn).
app.runWithContext(fn, { asyncAdvice: false });

Dev-only; ignored in production builds. The warning still fires at most once per app across all callers.

Writing plugins

A plugin is a function or an object with install(app, options?), registered with app.use(). Inside install, app._context is the supported surface for wiring app-wide services — pass it to seam provide-helpers (e.g. provideAsyncEngine from sigx/internals) or use app.defineProvide for injectables. The underscore marks it as an advanced surface, not a private one; no cast is needed:

import { defineInjectable, type Plugin } from '@sigx/runtime-core';

class MyService {
  close() { /* release sockets, timers, … */ }
}

export const useMyService = defineInjectable(() => new MyService());

export const myPlugin: Plugin = {
  name: 'my-plugin',
  install(app) {
    const service = app.defineProvide(useMyService);
    app._context.disposables.add(() => service.close());
  }
};

Seeding a pack from the SSR state blob

peekRestored(key) and invalidateRestored(key) are the public read and invalidate half of the page's __SIGX_ASYNC__ blob — the server fills it through ctx.registerSerializedState (@sigx/server-renderer), and a state-owning pack seeds from it on the client:

import { peekRestored, invalidateRestored } from '@sigx/runtime-core';

const seed = peekRestored('cart');            // { hit, value } — hit is own-key membership
if (seed.hit) state.set(copy(seed.value));    // COPY: the value is shared with the blob
// Only if this seed must not outlive this instance (the default leaves it for later mounts):
invalidateRestored('cart');

Reads do not consume — the blob is the page's data cache for its lifetime and every later mount seeds from it — and servers always miss. copy is whatever suits the pack's value shape: structuredClone covers plain data and the built-in codec types, but drops custom prototypes, so a value that can be a live class instance (written back after a client fetch) needs a copy that knows the type. The full contract is in the functions' JSDoc and docs/seams.md.

Taking a component factory apart

CombinedOf<F>, PropsOf<F>, RefOf<F> and SlotsOf<F> read the parameters a ComponentFactory was built with, for types that derive one factory from another without touching its @internal brands:

import type { AnyComponentFactory, ComponentFactory, CombinedOf, RefOf, SlotsOf } from '@sigx/runtime-core';

/** Swap an event on a factory, keeping its ref and slots. */
type Adapted<F extends AnyComponentFactory, TRemove extends string, TAdd> =
    ComponentFactory<Omit<CombinedOf<F>, TRemove> & TAdd, RefOf<F>, SlotsOf<F>>;

Non-web renderers

This package references no web global unguarded — it runs anywhere.

JSX and ctx are extension seams. Set jsxImportSource: "@sigx/runtime-core" (apps on sigx keep "sigx"). runtime-core ships the platform-neutral base of the global JSX namespace — JSX.Element, IntrinsicAttributes (key) and ElementChildrenAttribute — and deliberately no IntrinsicElements: the renderer declares the elements it can render by global merging, exactly as @sigx/runtime-dom does for HTML/SVG. Anything the renderer wants on ctx is a ComponentSetupContext augmentation, delivered at runtime by registerContextExtension (from @sigx/runtime-core/internals):

// terminal-jsx.ts — the elements this renderer knows
declare global {
    namespace JSX {
        interface IntrinsicElements {
            box: BoxProps;
            text: TextProps;
        }
    }
}

// the per-component context this renderer adds
declare module '@sigx/runtime-core' {
    interface ComponentSetupContext {
        /** The terminal this component renders into. */
        terminal: Terminal;
    }
}
registerContextExtension((ctx) => { (ctx as any).terminal = currentTerminal(); });

Both are plain interfaces, so several packages can merge into them.

One more thing renderer authors must know: useData/useStream only auto-run their sources on a live client, and without a declaration that is detected as "window exists" (which keeps server renders safe). A client runtime with no window (native, terminal) must say so once, from its platform-identity module:

import { declareLiveClient } from '@sigx/runtime-core/internals';

declareLiveClient(); // this runtime is a live client — keyed reads fetch on mount

Never call this from code a server render can evaluate (that would defeat the SSR guard) — it belongs in the module that defines your renderer's platform, the way @sigx/runtime-dom/platform defines the web's.

A declaration also stamps globalThis.__SIGX_LIVE_CLIENT__, which @sigx/server's live-client guard reads (rfc-server rev 2): a server function invoked in a declared live client throws instead of executing its body locally. The window fallback never stamps.

One copy per page

The current-instance slot, the app contexts and the DI tokens live in this package, so a realm must hold exactly one copy of it — as of @sigx/reactivity. A second copy evaluating in the same realm throws in development ([sigx] Two copies of @sigx/runtime-core are loaded: <version> at <url> and <version> at <url>.) and warns once in production. The cause is always an install with two resolutions — two versions in the lockfile, or a library that bundled its own copy — and the fix is the 1.0 install shape: the app installs sigx once, libraries peer on it (docs/rfc-1.0.md §3). The stamp is globalThis.__SIGX_RUNTIME_CORE__ (docs/seams.md).

Note: Most users should install sigx instead, which bundles this package with a DOM renderer and the reactivity system.

📚 Documentation

The complete export list (component model, JSX runtime, lifecycle, lazy/Defer, DI, control flow, directives, error handling), guides and live examples → https://sigx.dev/core/packages/runtime-core/overview/

License

MIT