Skip to content
gkoreliPublic

About

A reactive web component framework with signals, typed templates, routing, static generation, and source-owned UI components—no virtual DOM.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

nisli

A small, reactive web-component framework built on browser standards. Nisli combines fine-grained signals, typed component factories, light-DOM templates, dependency injection, routing, and static generation without a virtual DOM or a framework compiler.

Built in public

Nisli started inside backlog-mcp's web viewer rather than as a standalone framework. Two public records show that origin and its production use:

These are implementation records from the maintainer, not benchmarks.

Install

npm install @nisli/core

@nisli/core has no runtime dependencies. It works with TypeScript or plain JavaScript and does not require a framework-specific build step.

See the framework tenets for design constraints.

Quick start

import { component, html, signal } from '@nisli/core';

const Counter = component('x-counter', () => {
  const count = signal(0);

  return html`
    <button @click=${() => count.value++}>
      Count: ${count}
    </button>
  `;
});

html`${Counter({})}`.mount(document.body);

Signals are read with .value in TypeScript and passed directly to templates. Only the bound text, attribute, class, or child slot updates when a signal changes.

Native form bindings

html and el() synchronize native value (input, textarea, select), checked (input), and selected (option) properties as well as their attributes. An external signal change therefore replaces a user-edited value without a ref or an application effect:

const name = signal('');
html`<input value=${name} @input=${(event: Event) => {
  name.value = (event.target as HTMLInputElement).value;
}}>`;

Bindings remain one-way; native events update your model only through handlers. Equal text-value writes preserve the caret. Select values initialize after options and reconcile in a mutation-observer microtask when options change. A select value is scalar; bind option selected states for multiple selections. Nonempty file values cannot populate uploads; an empty value can clear them.

Form reset keeps native defaults: input value/checked and option selected attributes, textarea child text, and select option defaults. Reset does not write signals. Ordinary attributes and custom-element props keep their existing paths.

Components that also work as HTML

component() registers a standard custom element and returns its typed factory. Factory callers can pass plain values or signals. The optional attrs map makes selected host attributes live, so plain-HTML consumers and factory consumers share the same component implementation.

import {
  children,
  component,
  html,
  type ComponentAttrs,
} from '@nisli/core';

interface DisclosureProps {
  open?: boolean;
  children?: unknown;
}

const attrs = {
  open: 'boolean',
} satisfies ComponentAttrs<DisclosureProps>;

const Disclosure = component<DisclosureProps, typeof attrs>(
  'x-disclosure',
  (props) => {
    const content = children('Nothing to show');
    return html`
      <section class:open=${props.open}>
        ${content}
      </section>
    `;
  },
  { attrs },
);
html`${Disclosure({ open: true, children: 'Factory content' })}`;
<x-disclosure open>Plain HTML content</x-disclosure>

Declared string, boolean, number, and forwarded attributes react to setAttribute() after mount. children(fallback?) projects factory children, initial light-DOM children, and late parser children through one reactive slot.

Async derivations

Use resource() when a local value is derived asynchronously and does not need query caching or invalidation policy. Only the synchronous source function is tracked; stale work is aborted and cannot overwrite a newer result.

import { component, html, resource } from '@nisli/core';

const Markdown = component<{ content: string }>('x-markdown', (props) => {
  const rendered = resource(
    () => props.content.value || undefined,
    (content, signal) => renderMarkdown(content, { signal }),
  );

  return html`<article html:inner=${rendered.data}></article>`;
});

data, loading, and error are readonly signals. refresh() reruns the current source; component teardown disposes automatically, while standalone callers can use dispose(). Returning undefined from the source disables the resource and clears its current state.

Development and production diagnostics

The published @nisli/core root gives bundlers standard process.env.NODE_ENV guards. Vite production builds and esbuild browser bundles with full minification remove optional diagnostics and their bookkeeping. Other bundlers should use their normal production replacement and dead-code elimination settings.

Native browser ESM needs no application build: serve dist/index.js for development or dist/index.production.js for production, and map @nisli/core to that one URL for the entire application. Component imports stay unchanged. setDevMode() controls available development checks; it cannot restore code removed from a production artifact. Template safety and failure containment remain enabled in production.

See the core diagnostics and migration guide for the import map, runtime overrides, and production error behavior.

Explicit resource management

Standalone Nisli disposables support using on runtimes with native explicit resource management:

{
  using stop = effect(() => console.log(count.value));
  using tasks = query(() => ['tasks'], fetchTasks);
}

This applies to effect(), signal/computed subscribe(), Emitter.on(), resource(), and query(). Existing callable disposers and .dispose() methods are unchanged. Nisli only attaches the guarded Symbol.dispose alias; it never polyfills the platform. Apps that downlevel using for older runtimes must provide their own Symbol.dispose polyfill.

View transitions

viewTransition() wraps a state update in a native View Transition, calling flush() inside the browser's update callback so the frame it captures is Nisli's flushed DOM rather than the next microtask:

import { viewTransition } from '@nisli/core';

viewTransition(() => { items.value = sorted; }, { types: ['reorder'] });

The router opts in per application or per navigation, and wraps only the commit — an awaited loader never blocks paint:

defineRouter(catalog, { viewTransitions: { enabled: true } });
router.navigate('/blog', { viewTransition: false });

Static builds get the cross-document lane with no runtime JS at all: buildStaticSite({ viewTransitions: true }) emits the @view-transition { navigation: auto } opt-in both pages need, plus optional speculation rules, and whenActive() from @nisli/ssg/client holds observable work until a prerendered page is actually shown.

Everything degrades: no startViewTransition and the update still applies, flushed and unanimated, returning null. Nisli ships no stylesheet, and prefers-reduced-motion is answered in CSS so the swap stays atomic while the motion is cut.

Core capabilities

  • Fine-grained reactivity: signal, computed, effect, untrack, flush, and awaitable tick.
  • Typed web components: composition-style synchronous setup, signal-backed props, typed factories, and live attribute declarations.
  • Safe light-DOM templates: signal bindings, typed events and modifiers, class:*, branded html:inner (trusted raw() or sanitizer-routed sanitized()), refs, and dynamic HTML tags through el().
  • Stable control flow: lazy when() branches and keyed each() lists retain component state and DOM identity. Reorders use moveBefore() where available; the fallback keeps a retained focused row attached without refocusing it. Other moved rows can lose native state such as iframe documents or popovers on the fallback path; document Selection ranges are not preserved.
  • Two scopes of dependency injection: app-wide singleton services with inject/provide, and portal-safe subtree state with createContext.
  • Lifecycle and resilience: onMount, onCleanup, useHostEvent, automatic effect/event cleanup, move-resilient disconnects, and component error boundaries.
  • Async work: resource handles local async derivations; query and QueryClient add shared caching, invalidation, and reactive refetching.
  • Typed application events with Emitter and automatic component cleanup.

Ecosystem

This repository contains four independently versioned packages and the site that exercises them together:

  • @nisli/core — component authoring and browser runtime.
  • @nisli/router — one typed route catalog for browser, Vite, static builds, and edge/Worker matching. It includes codecs, redirects, managed SEO metadata, render-separated catalogs, and accessible outlets.
  • @nisli/ssg — renders normal Nisli templates and the shared application router to static output.
  • @nisli/ui — “shadcn for Nisli”: a CLI and source registry that copy accessible, Tailwind v4 components into your project.
  • packages/www — the private nisli.dev application and end-to-end integration surface.

Source-owned UI components

npm install -D @nisli/ui tailwindcss tw-animate-css
npx nisli-ui init
npx nisli-ui add button dialog tabs

Import tailwindcss, tw-animate-css, and the copied nisli-ui/styles/theme.css in that order. The copied ui-* components support typed Nisli factories and plain HTML; your application owns the resulting source.

One router across environments

import { html } from '@nisli/core';
import { defineRouter, route } from '@nisli/router';

export const AppRouter = defineRouter({
  home: route('/', {
    metadata: { title: 'Home' },
    render: () => html`<h1>Home</h1>`,
  }),
});

Mount AppRouter({}) in the browser, pass the same router to nisliRoutes(AppRouter) for Vite direct-route fallback, and pass it to buildStaticSite({ router: AppRouter }) for static output. Larger applications can author an environment-neutral catalog from @nisli/router/catalog and attach client renderers later with bindRenders().

Vite HMR

import { defineConfig } from 'vite';
import { nisliHmr } from '@nisli/core/vite-hmr';

export default defineConfig({
  plugins: [nisliHmr()],
});

The development-only plugin remounts an edited component in place without a full page reload.

Development

pnpm install
pnpm build
pnpm test
pnpm typecheck
pnpm exec playwright install chromium firefox webkit
pnpm --filter @nisli/core test:package

Architecture decisions live in docs/adr. Package-specific usage and release history live beside each package.

Inspiration

Nisli stands on the shoulders of React's component model, Solid's fine-grained reactivity, Lit's web components and templates, Angular's dependency injection, and Vue's composition-style authoring.

License

MIT

About

A reactive web component framework with signals, typed templates, routing, static generation, and source-owned UI components—no virtual DOM.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages