Kensington
GitHub

Kensington

HTML/SVG/MathML template library for JavaScript and TypeScript. Tags, attribute names/values, inline style property names, and some nested tags are comprehensively typed against the official specs. Components are plain functions with no JSX, no compiler, and very little to learn.

Reactive data via built-in signals.

npm install kensington

Quick start

Components are plain functions. Call .toString() to get an HTML string for server rendering or static generation.

JavaScript
import { t } from 'kensington';
function profileCard(name, title) {
  return t.article({ class: 'profile' },
    t.h2(name),
    t.p({ class: 'title' }, title),
    t.a({ href: `/users/${name.toLowerCase()}` }, 'View profile'),
  );
}
profileCard('Alice', 'Senior Engineer').toString();
Output
<article class="profile">
  <h2>Alice</h2>
  <p class="title">Senior Engineer</p>
  <a href="/users/alice">View profile</a>
</article>

For live DOM, call .toElement() instead. Pass a signal() anywhere a static value is accepted and the DOM updates automatically when the value changes.

JavaScript
import { t, signal, computed } from 'kensington';
const count = signal(0);
const label = computed(() => count.get() === 1 ? 'item' : 'items');
const counter = t.div([
  t.p([count, ' ', label]),
  t.button({ onclick: () => count.set(n => n + 1) }, '+'),
  t.button({ onclick: () => count.set(n => n - 1) }, '-'),
]).toElement();
document.body.append(counter);
TypeScript
import { t, signal, computed, Signal } from 'kensington';
const count: Signal<number> = signal(0);
const label = computed(() => count.get() === 1 ? 'item' : 'items');
const counter = t.div([
  t.p([count, ' ', label]),
  t.button({ onclick: () => count.set(n => n + 1) }, '+'),
  t.button({ onclick: () => count.set(n => n - 1) }, '-'),
]).toElement();
document.body.append(counter);

Signals work as content, as attribute values, inside style objects, and in the prop key. Calling .toElement() wires up all updates.

Why Kensington?

We wanted flying cars , instead we got 140 thousand hours of React tutorials. We find ourselves spending more time trying to parse framework docs, and less time actually writing code. Kensington hopes to alleviate some of these ails by presenting a simpler alternative to the frameworks. The basics can be learned in a few minutes, and the entire API can be learned in half an hour. Kensington handles the structural work automatically, leaving a simple api for a dev to parse.

There are no magic attributes to memorize, and no new HTML templating language to learn. It is plain JavaScript function and method calls. If you can read the code, you can guess what it does, even before reading the docs.

Comprehensive typing, lint rules, IDE plugins, and server integrations help keep your code clean. In case you let robots write your code, Kensington is very AI-friendly, and produces code that is simple enough to be reviewable by a human.

Building HTML

Elements & content

Every HTML, SVG, and MathML element is available as a method on t . All call forms work:

t.div({ id: 'app' }, 'text');      // options + content
t.div({ id: 'app' });              // options only
t.div('text');                     // content only
t.div([t.p('a'), t.p('b')]);       // content array
t.div();                           // empty

Void elements take only options (no content):

t.input({ type: 'checkbox', checked: true });
t.br();
t.meta({ charset: 'utf-8' });

Content can be strings, numbers, tags, arrays, or any mix. Arrays are flattened:

t.p(['Count: ', 42, t.strong(' items')]).toString();
// <p>Count: 42<strong> items</strong></p>

Attributes accept camelCase keys (converted to kebab-case), class as an array, and style as a plain object. Boolean attributes are included when true , omitted when false .

t.div({ id: 'app', class: ['card', 'shadow'] });         // class as array
t.input({ type: 'checkbox', checked: true });            // boolean attribute
t.p({ style: { color: 'red' } }, 'Warning');             // style object
t.div({ dataBsToggle: 'collapse' });                     // camelCase → data-bs-toggle

For the full reference including nested objects, data-* , aria-* , event handlers, and DOM properties, see Attributes & options in the Advanced section.

Rendering lists

Pass an array anywhere content is expected. Each element is rendered in sequence, so .map() is the natural way to render a list of items.

JavaScript
const items = ['Apples', 'Oranges', 'Pears'];
t.ul(items.map(item => t.li(item)));
// Nested: combine with objects
t.tbody(rows.map(row =>
  t.tr([t.td(row.name), t.td(row.role)])
));
HTML output
<ul>
  <li>Apples</li>
  <li>Oranges</li>
  <li>Pears</li>
</ul>

Todo list example Form from schema example

Conditionals

Falsy values ( null , undefined , false , '' ) are silently dropped from content. No conditional wrappers needed.

t.ul([
  t.li('always shown'),
  isLoggedIn && t.li(t.a({ href: '/logout' }, 'Log out')),
  show ? t.li('yes') : null,
]);

If isLoggedIn is false, the second li is simply absent from the output. The null in the third slot is dropped the same way.

Live filter example

Components & reuse

Plain functions work as components. No framework, no lifecycle, no magic. A component is just a function that takes arguments and returns a tag.

JavaScript
function card(heading, body) {
  return t.div({ class: 'card' }, [
    t.div({ class: 'card-header' }, heading),
    t.div({ class: 'card-body' }, body),
  ]);
}
t.div({ class: 'card-grid' }, [
  card('Alice', t.p('Role: Admin')),
  card('Bob',   t.p('Role: Editor')),
]);
HTML output
<div class="card-grid">
  <div class="card">
    <div class="card-header">Alice</div>
    <div class="card-body">
      <p>Role: Admin</p>
    </div>
  </div>
  <div class="card">
    <div class="card-header">Bob</div>
    <div class="card-body">
      <p>Role: Editor</p>
    </div>
  </div>
</div>

Because .toString() and .toElement() are just methods, the same component works in Node and in the browser with no changes.

Server rendering example Express render helper example Framework integration example Elysia example

Tag methods are bound to the instance, so you can destructure them and call them directly:

const { div, p, ul, li, span } = t;
div({ class: 'card' }, [
  p('Methods are bound, so destructuring works anywhere.'),
  ul([li('item one'), li('item two')]),
]);

Browser DOM

Standard event handler attributes ( onclick , oninput , etc.) accept a function (wired via addEventListener ) or a string (set via setAttribute ). For custom events, use the on key with a plain object mapping event names verbatim to handlers. SVG and MathML elements get the correct namespace automatically.

import { t } from 'kensington';
const button = t.button({ type: 'button' }, 'Click me').toElement();
document.body.append(button);
// custom events: names are passed verbatim to addEventListener
const el = t.div({
  on: {
    bricksSelectorChange: e => console.log(e.detail),
    'my-custom-event':    e => console.log(e.detail),
  },
}).toElement();
// SVG gets the correct SVGElement namespace
const svg = t.svg({ viewBox: '0 0 100 100' }, [
  t.circle({ cx: 50, cy: 50, r: 40, fill: 'steelblue' }),
]).toElement();
document.body.append(svg);

Use .toElement() to get a live DOM element. It is safe to call before the element is mounted.

Counter example

Reactive Data

Wrap any value in signal() and pass it into a tag. When the signal changes, only the affected text node or attribute updates in place. Nothing re-renders.

import { t, signal } from 'kensington';
const count = signal(0);
document.body.append(
  t.div([
    t.p(['Count: ', count]),
    t.button(
      { onclick: () => count.set(count.value + 1) },
      '+1'
    ),
  ]).toElement()
);
Live result

Count: 0

signal(value)
Creates a reactive value. Pass it anywhere a static value is accepted . content, attributes, or DOM properties. And the DOM updates automatically when the value changes.
computed(fn)
Derives a new value from other signals. Use it for calculated state that depends on reactive data. Stays in sync automatically whenever its dependencies change.
effect(fn)
Runs a callback whenever the signals it reads change. Use it for side effects outside the DOM. Page title, localStorage, analytics, or any imperative update.

Full reactivity guide: computed, effects, lifecycles, server rendering →

TypeScript

Attribute types

Types are generated directly from the HTML, SVG, and MathML living standards. Attribute names are checked, attribute values are typed as enums, booleans, or numbers as appropriate, and the style object is typed with csstype . You get a compile-time error when a value is wrong.

Content model

Strict containers enforce which children are valid at compile time. Passing a div to t.tr() is a type error. Branded return types ( TdTag , LiTag , ImgTag , etc.) extend ContentTag , so existing code that types values as ContentTag still works.

TypeScript types are also generated for custom elements and module augmentation. See Custom elements in the Advanced section.

Tooling

HTML → Kensington

The kensington CLI converts existing HTML to Kensington code. Paste it in the terminal, pipe a file, or pass a filename.

HTML input
<nav class="navbar" aria-label="main" aria-expanded="true">
  <a href="/" class="nav-link">Home</a>
  <a href="/about" class="nav-link">About</a>
</nav>
Kensington output
t.nav({ class: "navbar", aria: { label: "main", expanded: "true" } }, [
  t.a({ href: "/", class: "nav-link" }, "Home"),
  t.a({ href: "/about", class: "nav-link" }, "About"),
])
Mode Command
Interactive npx kensington (paste in the terminal)
File npx kensington index.html
Pipe echo '<p>hello</p>' | npx kensington
Redirect npx kensington < page.html
Flag Description
--copy , -c Copy output to clipboard
--help , -h Print usage

If ESLint or Prettier is present in the working directory, the converter runs the formatter over the output.

IDE plugins

CSS class completions and diagnostics inside Kensington class strings. Both plugins read your local stylesheets and any CDN stylesheets linked via t.link in your project.

Completions
Diagnostics

Available for VS Code and JetBrains IDEs. Both plugins also wire up Go to Definition and Find Usages between CSS selectors and Kensington templates.

ESLint plugin

kensington-eslint-plugin catches common signal mistakes at lint time: writes inside computed derivations, orphaned effects, async pitfalls, and more. Requires ESLint 9+ and Node 18+.

npm install --save-dev 'kensington-eslint-plugin@^0.5.0'

Add the strict config to your eslint.config.js (this is the canonical setup for new projects):

import kensington from 'kensington-eslint-plugin';
export default [
  kensington.configs.strict,
  // ...your other configs
];

Chain kensington-check-reactive into your lint script so cross-file reactive traps are caught alongside ESLint:

"scripts": {
  "lint": "eslint . && kensington-check-reactive src --quiet"
}
Error
Warning

DevTools panel

import 'kensington/devtools';

A floating overlay that tracks every signal, effect, and DOM binding live. Click the K badge in the bottom-right corner to open it. Guard the import so it does not run in production. See Devtools on the Reactive data page for setup options and a full tab reference.

Server packages

Drop-in view rendering for Express and Fastify. Each package attaches a renderView() method to the response that applies a layout, merges locals, and sends the HTML string. See the kensington-express and kensington-fastify examples for full usage.

Package Description
kensington-express Express middleware adding res.renderView(pageRenderer, options?)
kensington-fastify Fastify plugin adding reply.renderView(pageRenderer, options?)

AI assistants

Kensington ships an AGENTS.md file at the package root. It is a compact, single-file reference of the full API: method signatures, attribute rules, constructor options, TypeScript types, the CLI, and working examples. AI coding assistants can read it to answer questions and generate accurate Kensington code.

Using it

Most AI editors and assistants let you add files as context. Point yours at AGENTS.md and it will have everything it needs to work with Kensington correctly:

Advanced Usage

The above usage may be enough for many projects, but if you are building a more complex app, you may need these tools.

Attributes & options

camelCase keys { dataBsToggle: 'collapse' }data-bs-toggle="collapse" . SVG attributes like viewBox and gradientUnits pass through unchanged.
Nested objects { data: { bs: { toggle: 'collapse' } } }data-bs-toggle="collapse"
Boolean attributes { checked: true }checked . { checked: false } → attribute omitted.
class as array { class: ['foo', 'bar'] }class="foo bar"
data-* and aria-* Always allowed on every element, along with all global HTML attributes .
style as object

{ style: { backgroundColor: 'red', zIndex: 2 } }style="background-color: red; z-index: 2"

camelCase keys always convert to kebab-case. CSS property names are always kebab-case (including for SVG); camelCase is only the JavaScript DOM convention for element.style . null , undefined , and false values are silently omitted. In TypeScript, the style object is typed with csstype for autocomplete on property names and values.

on key { on: { myCustomEvent: handler } } wires listeners via addEventListener . Event names are passed verbatim. Use this for custom or camelCase event names that on* attributes cannot express. Silently ignored in .toString() .
prop key { prop: { value: 'hello' } } assigns directly to DOM properties ( el.value = ... ) instead of setAttribute . Silently ignored in .toString() .

Dev vs production

Two settings are worth flipping between local development and production. Use them together to catch attribute typos and bad values during development while shipping a small bundle to users.

Validation in development

By default, validationLevel is 'off' . In development, set it to 'warn' or 'error' so invalid attribute names and values are reported at runtime instead of silently rendering. TypeScript catches most issues at compile time. This catches the rest (dynamic attribute names, JS callers, and any code path TypeScript can't reach).

import Kensington from 'kensington';
const t = new Kensington({ validationLevel: 'error' });
t.input({ type: 'checkbox' });   // fine
t.input({ type: 'notatype' });   // throws. Not an allowed value
t.div({ unknownAttr: 'x' });     // throws. Not a known attribute

See Validation below for the full options and behavior.

Slim build for production

The slim build is a separate bundle that ships without per-element attribute spec data. The minified output drops from ~148 KB to ~27 KB, about 5× smaller. The public API is identical. Tags, attributes, signals, and hydration all work the same.

Since the slim build has no spec data, runtime validation is unavailable. The constructor throws if you set validationLevel to anything other than 'off' .

import Kensington from 'kensington/dist/slim';
const t = new Kensington();   // validationLevel defaults to 'off'
t.div({ class: 'card' }, t.p('Hello'));

Wiring it up with Vite

Use a Vite alias to swap the import target by build mode. Your application code stays as import Kensington from 'kensington' everywhere. Vite resolves to the full build in dev and the slim build in production.

// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => {
  const alias = mode === 'production' ? { kensington: 'kensington/dist/slim' } : {};
  return { resolve: { alias } };
});

Pick the validation level from Vite's build environment so dev gets runtime checks and prod gets the no-op fast path.

// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: import.meta.env.DEV ? 'error' : 'off',
});

Use t everywhere in your app. npm run dev loads the full build with errors on bad attributes. npm run build produces a bundle backed by the slim runtime.

The same pattern works with other bundlers. See Rollup , esbuild , and Webpack in the examples page for equivalent setups.

Constructor options

import Kensington from 'kensington';
const t = new Kensington({
  validationLevel: 'warn',        // 'off' | 'warn' | 'error', default 'off'
  additionalNamespaces: ['hx'],   // allow hx-* (htmx), x-* (alpine), etc.
  additionalGlobalAttributes: {   // allow specific attributes on every element
    popover: ['auto', 'manual'],  // string enum
    nonce: String,                // any string value
    inert: Boolean,               // boolean attribute
  },
  indentationLevel: 2,            // spaces per indent, default 2, 0 to disable
  logger: msg => myLogger(msg),   // receives validation warnings, default console.log
});

Validation

Level Behavior
'off' No validation. Best for production. Default.
'warn' Logs via logger (default console.log ). Does not throw.
'error' Throws an Error . Useful for CI or strict development environments.
const t = new Kensington({ validationLevel: 'error' });
t.div({ class: 'ok' });         // fine
t.div({ unknownAttr: 'x' });    // throws: not a known attribute
t.input({ type: 'checkbox' });  // fine
t.input({ type: 'notatype' });  // throws: not an allowed value

Custom elements

import Kensington from 'kensington';
class MyEngine extends Kensington {
  myCard = this.createCustomTag('my-card', {
    cardType: ['primary', 'secondary'],            // allowed string literals
    loading: Boolean,                              // boolean attribute
    maxItems: Number,                              // numeric attribute
    score: v => typeof v === 'number' && v <= 100, // custom validator function
  });
}
const t = new MyEngine();
t.myCard({ cardType: 'primary' }, t.p('content')).toString();
// → <my-card card-type="primary">
//     <p>content</p>
//   </my-card>

To extend a built-in element with extra attributes, import its attribute object from kensington/attributes and spread it into createCustomTag :

import Kensington from 'kensington';
import { buttonAttributes } from 'kensington/attributes';
class MyEngine extends Kensington {
  button = this.createCustomTag('button', {
    ...buttonAttributes,
    popovertarget: String,  // add an attribute not yet in the spec data
  });
}
const t = new MyEngine({ validationLevel: 'error' });
t.button({ type: 'button', popovertarget: 'my-popover' }, 'Open').toString();

Every element in the spec has a corresponding named export ( divAttributes , inputAttributes , …) available from kensington/attributes .

Use ContentMethod<T> to type a custom element method, and module augmentation to allow custom attribute namespaces without a subclass:

import Kensington, { type ContentMethod } from 'kensington';
class MyEngine extends Kensington {
  myCard: ContentMethod<{ cardType?: 'primary' | 'secondary'; loading?: boolean }> =
    this.createCustomTag('my-card', { cardType: ['primary', 'secondary'], loading: Boolean });
}
declare module 'kensington' {
  interface NameSpaceAttributes {
    [key: `hx${string}`]: string | object;
  }
}
t.div({ hxBoost: 'true' });  // now valid

htmx integration example Tailwind example Alpine.js example

Persist effects

By default, .toElement() stops signal effects permanently when an element is removed from the DOM. For elements that will be moved or temporarily removed and re-inserted, add persist: true to the tag options. Effects are paused on removal and resume automatically on re-insertion, across any number of cycles.

// Without persist: true, removing the item during a drag-reorder permanently stops
// its signal effects (class, checked, etc.).
// With persist: true, effects pause on removal and resume when the node is re-inserted.
const item = t.li({ class: statusClass, persist: true }, [
  t.input({ type: 'checkbox', checked: task.done }),
  t.span(task.text),
]);

persist: true is silently ignored in .toString() and has no effect on server-side rendering. It only changes behavior when an element created by .toElement() is removed and re-inserted into the DOM.

Raw HTML & comments

t.literal('<li>verbatim, HTML-encoded</li>');    // <script> tags flagged via validationLevel
t.unsafeLiteral('<li>trusted HTML, no encoding</li>');
t.inlineComment('hello world');          // <!-- hello world -->
t.inlineComment('line 1\nline 2');       // <!--\n  line 1\n  line 2\n-->

Preformatted text example

Complete examples covering SSR, htmx, forms, icon reuse, and reactive patterns are on the Examples page .

GitHub

Reactive data

Pass a signal() anywhere a static value is accepted (as an attribute value, content, or DOM property) and Kensington wires up live DOM updates automatically. When the signal changes, only the affected attribute or text node is updated in place.

Signals

A signal holds a reactive value. Read it with .get() and write it with .set() . Anything using the signal updates automatically when the value changes.

import { signal, t } from 'kensington';
const busy = signal(false);
const result = signal('Press the button to fetch a quote.');
function fetchQuote() {
  busy.set(true);
  fetch('/api/quote')
    .then(r => r.json())
    .then(data => result.set(data.text))
    .finally(() => busy.set(false));
}
document.body.append(t.div([
  t.p(result),
  t.button({ type: 'button', disabled: busy, onclick: fetchQuote }, 'Fetch quote'),
]).toElement());

Counter example

computed

A read-only signal derived from others. Re-evaluates automatically when any dependency changes.

const firstName = signal('Ada');
const lastName = signal('Lovelace');
const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
// fullName re-evaluates whenever either signal changes
t.p(fullName).toElement();

effect

Runs immediately and re-runs whenever any signal read via .get() inside it changes. Use for side effects that live outside the DOM: document.title , localStorage , analytics, etc.

const count = signal(0);
const e = effect(() => {
  // runs whenever count changes
  document.title = `${count.get()} items`;
});

Content

Pass a signal as an element's content (or anywhere in a content array) and the text node updates in place when the signal changes.

const count = signal(0);
const label = computed(() => count.get() === 1 ? 'item' : 'items');
t.p([count, ' ', label]).toElement();
count.set(3);  // renders "3 items"

A signal returning an array replaces its placeholder nodes on each change. A signal returning null or undefined renders nothing.

Attributes

Pass a signal as any attribute value. The attribute is set, removed, or toggled automatically when the signal changes.

const isLoading = signal(false);
const cls = computed(() => isLoading.get() ? 'btn-secondary' : 'btn-primary');
t.button({ class: cls, disabled: isLoading }, 'Save').toElement();
isLoading.set(true);   // disables button and changes class
isLoading.set(false);  // restores it

Character counter example Dark mode example

Reactive style properties

Two reactive shapes are supported. Use whichever fits the data flow.

Per-property signals

Individual properties inside a style object accept signals. Only the changed property is written to the DOM on each update. All other properties are left untouched.

const color = signal('red');
const opacity = signal(1);
t.div({
  style: {
    color,             // reactive. Only color is updated when the signal changes
    opacity,           // reactive. Only opacity is updated when the signal changes
    fontSize: '1rem',  // static. Set once at render time
  },
}).toElement();
color.set('blue');   // writes el.style.setProperty('color', 'blue')
opacity.set(0.5);    // writes el.style.setProperty('opacity', '0.5')

A signal that resolves to null , undefined , false , or '' calls removeProperty on that property.

Whole-style signals

The style slot also accepts a signal that yields the entire style object. Each emission is diffed per-property against the previous; properties that changed are written, properties that disappeared from the new object are cleared via removeProperty . Use this when one derived signal naturally produces the whole bundle, such as a computed position.

const pointer = signal({ x: 0, y: 0 });
const position = computed(() => ({
  position: 'absolute',
  top: `${pointer.get().y}px`,
  left: `${pointer.get().x}px`,
}), 'position');
t.div({ id: 'cursor', style: position }).toElement();
// pointer.set({ x: 40, y: 80 }) writes top + left in one emission;
// properties removed from a later emission are cleared from el.style.

The same shape works at any depth inside data , aria , and any other namespaced-attribute slot. data: signal({foo: 'bar'}) flattens to data-foo="bar" , and data: { bs: signal({toggle: 'collapse'}) } flattens to data-bs-toggle="collapse" . prop and on do not support whole-object signals; use per-property signals there.

In .toString() , all signal values are resolved to their current value inline.

DOM properties

Sets a property instead of an attribute. input.value reflects what the user typed, while getAttribute('value') still returns the original default. Use the prop key to assign directly to DOM properties via el[name] = value , bypassing setAttribute :

const userInput = signal('');
// Assigns el.value = '' reactively, keeping the live property in sync
t.input({ type: 'text', prop: { value: userInput } }).toElement();
// Resetting
userInput.set('');  // el.value resets immediately
// Properties with no HTML attribute equivalent
const isMuted = signal(true);
t.video({ src: '/intro.mp4', prop: { muted: isMuted, playbackRate: 1.5 } }).toElement();
isMuted.set(false); // unmutes video

With .literal and .inlineComment

const html = signal('<b>bold</b>');
t.div(t.literal(html)).toElement();
// element is replaced when html changes
const note = signal('draft');
t.div([t.p('content'), t.inlineComment(note)]).toElement();
// comment nodeValue updates live

Live signals

A live signal is a signal whose value is shared by name across every connected browser and the server. Reads from any tab reflect writes from any other tab. Reads on the server see the same value. The API is the same as signal() .

A shared file declares the signal once and exports it. Both the SSR component and the server-side observer use the same exported instance.

// shared/viewers.js
import { t } from 'kensington';
import { liveSignal } from 'kensington/live';
export const viewerCount = liveSignal(0, 'home:viewers');
export function viewers() {
  return t.div([
    t.span([viewerCount, ' people viewing. ']),
    t.button({ onclick: () => viewerCount.set(n => n + 1) }, 'I am here'),
  ]);
}

The server wires up the live transport, serves the SSR markup for the component, and can read the same signal to react to client writes.

// server.js
import http from 'node:http';
import express from 'express';
import { effect, renderForHydration } from 'kensington';
import { liveServer } from 'kensington/live';
import { viewerCount, viewers } from './shared/viewers.js';
const app = express();
app.use(express.static('public')); // serves /client.js and other assets
app.get('/', (req, res) => {
  res.type('html').send(`<!doctype html>
<script type="module" src="/client.js"></script>
${renderForHydration(viewers, {})}`);
});
const httpServer = http.createServer(app);
const live = await liveServer();
await live.attach(httpServer);
// The server sees the same value. Each client write fires this effect.
effect(() => console.log('viewers:', viewerCount.get()));
httpServer.listen(3000);

The client opens the WebSocket and hydrates the shared component.

// client.js
import { registerComponents } from 'kensington';
import { connectLive } from 'kensington/live';
import { viewers } from './shared/viewers.js';
connectLive();
registerComponents({ viewers });

Persistence, permissions, and writes that need to converge across concurrent clients are covered in live signals under Advanced.

Advanced Usage

The above usage may be enough for many projects, but if you are building a more complex app, you may need these tools.

.value

Use .value instead of .get() inside effect() or computed() when you need the current value of a signal without subscribing to changes:

const searchTerm   = signal('');
const previousTerm = signal('');
// Re-runs when searchTerm changes. previousTerm.value reads without subscribing.
// Using .get() would subscribe the effect to previousTerm, and the .set()
// in the callback would re-trigger the effect, firing a duplicate request.
effect(() => {
  const current = searchTerm.get();
  const previous = previousTerm.value;
  const isRefinement = current.startsWith(previous) && previous.length > 0;
  fetch(`/search?q=${current}`)
    .then(r => r.json())
    .then(data => {
      results.set(data);
      previousTerm.set(current);
    });
});

Incremental search example

.transform

Returns a new read-only signal whose value is derived by passing the source signal's value through a function. Equivalent to computed(() => fn(source.get()), key) , but attached directly to the signal.

const count = signal(0);
const label = count.transform(n => n === 1 ? '1 item' : `${n} items`);
t.p(label).toElement(); // "0 items", updates when count changes
// useful for coercing a signal's type before passing it as an attribute
const sortAsc = signal(true);
t.th({ ariaSort: sortAsc.transform(v => v ? 'ascending' : 'descending') });

Inside a computed callback, pass an optional stable key as the second argument to scope the transform to the owning computed. Same lifecycle as computed(fn, key) : the same instance is reused across outer re-runs, the fn closure is updated automatically, and the instance is stopped when its key leaves the list.

const filter = signal('fruit');
const list = items.mapWithKey('id', item =>
  t.li({
    class: filter.transform(f => f === item.category ? 'match' : '', item.id),
  }, item.name),
);

Keyed lists

When a signal holds an array, the most direct way to render it is to .transform the signal and use a plain array.map . This works. The library will pick up every change and the UI stays in sync.

const items = signal([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
]);
// Plain map. Correct, but every render builds fresh tag instances and fresh DOM
// for each one. Adding a row rebuilds every existing <li>.
t.ul(items.transform(arr => arr.map(item => t.li(item.name)))).toElement();

The catch is performance. arr.map(item => t.li(...)) produces a fresh tag for every item on every re-render, so Kensington cannot tell that the new <li> at position 0 is the same Apple as before and builds a fresh DOM node. For a 10-row list this is invisible. For 1000 rows with frequent updates the cost adds up. Focus, scroll, and input value also reset because the DOM nodes are new each time.

signal.mapWithKey(keyOrProp, mapFn) is the optimized form. It runs mapFn once per key the first time the key is seen and caches the resulting tag. Subsequent renders return the same tag instance, so Kensington reuses the existing DOM node. Reordering, adding, and removing items reorder existing nodes rather than rebuilding them.

const items = signal([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
]);
// Property-name string shortcut. Equivalent to passing item => item.id.
const rows = items.mapWithKey('id', item => t.li(item.name));
t.ul(rows).toElement();

Two argument forms for the first argument:

mapWithKey returns a ReadonlySignal<Tag[]> . Pass it directly into tag content. Calling mapWithKey inside a computed or effect callback logs a warning because the per-key cache would reset on every outer re-run. Call it at the same scope where you call signal() .

Duplicate keys in the same render fire a console.error and the first item wins. The duplicate is silently skipped, so each unique key always corresponds to exactly one cached tag.

Sortable table example

Per-item local state and derived values

Inside mapWithKey 's mapFn, the same keying rules that apply inside any computed callback apply here. Pass the item id as the key to signal() , computed() , or .transform() so each per-item instance is scoped to the row and stopped automatically when the item leaves the list.

const filter = signal('fruit');
const list = items.mapWithKey('id', item => {
  // signal(initial, key). Per-item local interactive state.
  const highlight = signal(false, item.id);
  // computed(fn, key). Derived value that reads multiple signals.
  const cls = computed(() => [
    filter.get() === item.category && 'match',
    highlight.get() && 'on',
  ].filter(Boolean).join(' '), item.id);
  // signal.transform(fn, key). Single-source derivation chained off filter.
  const stateLabel = filter.transform(f => f === item.category ? 'in' : 'out', item.id);
  return t.li({
    class: cls,
    data: { state: stateLabel },
    onclick: () => highlight.set(v => !v),
  }, item.name);
});
t.ul(list).toElement();

Because mapFn only runs the first time a key is seen, these per-item primitives are created once and live for the life of the row. Removing the item drops it from the list and its primitives are stopped automatically. See the editable rows example for a realistic use of these patterns together.

Don't reference a keyed instance from outside its scope

A keyed primitive is stopped whenever its key isn't accessed during a re-run (e.g. when the item leaves the list). After that point, external subscribers held in user-land code silently stop receiving updates. Use the instance freely inside the mapFn (read it with .get() , transform it, pass it as tag content or an attribute value, etc.), but don't let the instance reference itself escape. The unsafe patterns are assigning it to a module-level variable, returning it bare from the mapFn, or passing it to a function that retains it.

The library emits a runtime warning, and the no-out-of-scope-reactive-reference ESLint rule catches it statically, when a keyed instance is referenced from outside its owner.

When you need a key

Always pass a key to a nested signal() . An unkeyed signal inside a computed loses its local state on every outer re-run.

For computed() and .transform() , pass a key when the result is read from user code (a signal() handler, another computed() , or an effect() ). Skip the key when the result is only passed to a tag as an attribute value, class entry, text child, or prop. Those inline uses are handled by the runtime and don't need a key.

When updates fire

A signal notifies its subscribers when .set() is called with a value that differs from the current one. The check is reference-style ( Object.is ), not deep.

Value type What "differs" means
string , number , boolean , null , undefined Different value. signal.set(3) when the current value is 3 is a no-op.
Array , Object , anything else Different reference. Mutating the existing value in place does not count. You must produce a new array or object.

This is the most common source of "my signal isn't updating" confusion. The fix is to update immutably.

Immutable update patterns

The same shapes work for any reactive library and all have built-in support in modern JavaScript.

// Array: replace one item by id, keep the others
items.set(prev => prev.map(it => it.id === 5 ? { ...it, done: true } : it));
// Array: add an item
items.set(prev => [...prev, newItem]);
// Array: remove an item
items.set(prev => prev.filter(it => it.id !== 5));
// Object: change one field
user.set(prev => ({ ...prev, name: 'Ada' }));
// Nested object: change a deep field (each level needs a spread)
state.set(prev => ({
  ...prev,
  profile: { ...prev.profile, name: 'Ada' },
}));

If a field needs to update frequently and is deeply nested, give it its own signal rather than reaching for spreads on every level. See Per-row signals below.

What does not trigger an update

const items = signal([{ id: 1, label: 'a' }, { id: 2, label: 'b' }]);
// 1. Mutating an element of the array. No update.
items.get()[0].label = 'changed';
// 2. Setting the signal to the same array reference. No update.
items.set(items.get());
// 3. Mutating then re-setting with the same reference. Still no update.
items.get()[0].label = 'changed';
items.set(items.get());
// 4. In-place array methods like push, pop, shift, unshift, splice, sort, reverse.
//    All mutate the existing array. The signal isn't notified.
items.get().push({ id: 3, label: 'c' });
items.get().splice(0, 1);   // remove the first item
items.get().sort((a, b) => a.label.localeCompare(b.label));
// 5. Object.assign on an existing object. The returned value is the same target reference,
//    so even re-setting after it does nothing.
Object.assign(items.get()[0], { label: 'changed', done: true });
// 5a. Capturing the array first, mutating, then re-setting doesn't help either. `arr` is
//     the same reference as items.get(), so signal.set short-circuits via Object.is. The
//     value returned by Object.assign is also the same target reference.
const arr = items.get();
Object.assign(arr[0], { label: 'changed', done: true });
items.set(arr);                       // no update

All five patterns leave the DOM stale. The first, fourth, and fifth update internal state but never tell the signal anything happened. The second, third, and 5a get short-circuited because Object.is(items.get(), items.get()) is true regardless of whether the value was mutated in between.

Mutating helpers like splice , sort , and Object.assign are particularly easy to reach for because they look like they "update" the value. They do, but the signal doesn't know. The non-mutating forms work as expected:

// Remove an item: filter to a new array
items.set(prev => prev.filter((_, i) => i !== 0));
// Add an item: spread into a new array
items.set(prev => [...prev, { id: 3, label: 'c' }]);
// Sort: toSorted returns a new array (ES2023+, or use [...prev].sort())
items.set(prev => prev.toSorted((a, b) => a.label.localeCompare(b.label)));
// Patch fields on an item: spread the item into a new object
items.set(prev => prev.map(it =>
  it.id === 1 ? { ...it, label: 'changed', done: true } : it,
));

When the DOM actually updates

Once a signal fires, what happens to the DOM depends on where the signal is used.

Use site What updates
t.div(signal) (signal as content) The text node (or the matching set of child nodes for an array signal) is patched in place. Surrounding content is untouched.
t.input({ value: signal }) (signal as attribute) Just that attribute. setAttribute is called. Boolean attributes are added or removed.
t.input({ prop: { value: signal } }) (signal as DOM property) Just that property. element[prop] = value is called. Required for things like input.value after the user has typed into the field.
effect(() => ...) inside The effect re-runs. Multiple .set() calls in the same synchronous turn are batched into a single re-run.
computed(() => ...) inside The computed re-evaluates synchronously. Its subscribers then update as above.

Per-row signals for fine-grained updates

For lists where individual rows change often, store a signal on each item rather than reactively re-rendering the entire array.

// The whole `items` array doesn't need to re-render when one row's done flag flips.
const items = signal([
  { id: 1, label: 'Buy milk', done: signal(false) },
  { id: 2, label: 'Walk dog', done: signal(true)  },
]);
function row(item) {
  return t.li(
    { class: item.done.transform(d => d ? 'done' : 'open') },
    item.label,
  );
}
const list = t.ul(items.mapWithKey('id', row)).toElement();
// Update one row. The parent `items` signal does not fire. Only the affected element's
// class attribute is rewritten. Adding or removing a row still uses items.set() with a
// fresh array.
items.get()[0].done.set(true);

mapWithKey is built for the array-set path (adding, removing, reordering rows). Per-row signals are the right tool when only a row's contents change.

Existing elements

When most of a page is static HTML, it is simpler to reach into the DOM with querySelector and drive updates with effect() directly rather than rebuilding large chunks of markup with .toElement() .

import { t, signal, effect } from 'kensington';
const theme = signal('light');
// Toggle a class on a single element
const root = document.documentElement;
effect(() => {
  root.classList.toggle('dark', theme.get() === 'dark');
});
// Drive a set of elements from one signal
const currentTab = signal('overview');
document.querySelectorAll('[data-tab-content]').forEach(el => {
  effect(() => {
    el.classList.toggle('hidden', el.dataset.tabContent !== currentTab.get());
  });
});
// Update text content
const count = signal(0);
const label = document.getElementById('count-label');
effect(() => {
  label.textContent = count.get();
});

Cleanup

Elements created with .toElement() automatically stop their reactive effects when the element is removed from the DOM, whether by el.remove() or by removing an ancestor.

const count = signal(0);
const el = t.p(count).toElement();
document.body.append(el);
el.remove(); // effect tracking count stops automatically

To pause effects instead of stopping them, add persist: true to the tag options. Effects resume automatically when the element is re-inserted, and pause again if it is removed a second time. This works across unlimited cycles.

const cls = signal('idle');
const el = t.div({ class: cls, persist: true }).toElement();
document.body.append(el);
el.remove();               // effects pause. cls.set() has no DOM effect
document.body.append(el);  // effects resume
cls.set('active');         // DOM updates immediately

For effects that run outside of any element, call e.pause() to temporarily unsubscribe and e.resume() to restart. Call e.stop() when the effect is no longer needed. It permanently destroys it and resume() becomes a no-op. To tie an effect to a component's lifetime without manual bookkeeping, use addDisconnectedCallback . See Lifecycle below.

Lifecycle

Kensington tag objects support lifecycle callbacks via addConnectedCallback(fn) and addDisconnectedCallback(fn) , mirroring the web component lifecycle. Call them on a tag object before calling .toElement() . Both methods return this and can be called multiple times to register multiple handlers. Callbacks receive the live DOM element as both the first argument and as this , matching web component convention.

addConnectedCallback

Fires when the element is inserted into the DOM. Use it for initialization that requires DOM presence, such as reading layout dimensions, starting side effects that should only run while the element is mounted, or initializing third-party libraries that need a live element.

const panel = t.div({ class: 'panel' }, content);
panel.addConnectedCallback(function(el) {
  // el (and `this`) is the DOM element. Layout is readable here
  const { width } = el.getBoundingClientRect();
  el.dataset.initialWidth = width;
});
document.body.append(panel.toElement()); // callback fires here

By default the callback fires once per toElement() call and is cleared when the element is removed. With persist: true in the tag options, all connected and disconnected callbacks re-fire on every cycle.

const tag = t.div({ persist: true }, content);
tag.addConnectedCallback(setup);
tag.addDisconnectedCallback(teardown);
tag.toElement();  // both callbacks re-fire on every insert/remove cycle

addDisconnectedCallback

Fires when the element leaves the DOM, after its signal effects have been cleaned up. Use it for cleanup that signals cannot handle automatically, such as clearing intervals and timers, destroying third-party library instances, or removing portal elements.

By default the callback fires once and is not re-registered. With persist: true in the tag options, all disconnect callbacks re-fire on every removal.

let intervalId;
const ticker = t.div({ class: 'ticker' }, price);
ticker.addDisconnectedCallback(() => {
  clearInterval(intervalId);
});

For a complete example combining both callbacks with an interval timer and a portal element, see the lifecycle widget on the Examples page.

Server-rendered reactive data

Server-render a component to HTML with renderForHydration , then pick it up on the client with registerComponents . The SSR output is replaced with a live, reactive DOM tree using the same state that was passed on the server.

// server.js
import { renderForHydration, t } from 'kensington';
import { counter } from './components/counter.js';
app.get('/', (req, res) => {
  res.send(
    t.htmlWithDocType({ lang: 'en' }, [
      t.head([t.meta({ charset: 'utf-8' }), t.title('App')]),
      t.body(renderForHydration(counter, { count: 0 })),
    ]).toString()
  );
});
// client.js
import { registerComponents } from 'kensington';
import { counter } from './components/counter.js';
registerComponents({ counter });

The component function runs on both server and client. Write it so it works in both environments:

// components/counter.js
import { t, signal, effect, isBrowser } from 'kensington';
export function counter({ count: initial }) {
  const count = signal(initial);
  // effect() is a no-op on the server: safe to use browser globals inside
  effect(() => {
    document.title = `Count: ${count.get()}`;
  });
  // isBrowser guards code that can't go inside effect()
  const stored = isBrowser ? localStorage.getItem('count') : null;
  return t.div([
    t.p(count),
    t.button({ type: 'button', onclick: () => count.set(n => n + 1) }, '+'),
  ]);
}
Export Context Description
renderForHydration(fn, state, name?, options?) Server Renders the component to HTML and embeds state as a JSON script block. fn is called as fn(state, context) where context comes from options.context (see below) and is never serialized. Uses fn.name by default server-side. Pass an explicit name for anonymous functions and when calling in the browser. Function names are not safe after minification. name must match what is used in registerComponents on the client. Throws if the component returns a non-element value or a Promise. Warns on lossy state values (Date, Map, Set, RegExp, undefined, function, Symbol, non-finite numbers, class instances); throws on unserializable ones (BigInt, circular references).
registerComponents(components, options?) Client Scans the page for components rendered by renderForHydration and mounts each one reactively. Each fn is called as fn(state, context) where context comes from options.context . Object keys are used as component names: { counter } registers the function under 'counter' . Must match what is passed in renderForHydration on the server. Issues a console.warn for unregistered component names and missing mount points. If the client component returns null or throws, warns or logs the error and leaves the SSR element in place. Defers hydration until DOMContentLoaded if called while the page is still loading. Components in dynamically fetched HTML fragments are hydrated automatically, without re-calling registerComponents . Returns { stop() } to stop watching for new components.
options.context Both Non-serializable runtime bag passed as the second argument to every component invocation. The server provides its own via renderForHydration , the client provides its own via registerComponents , and the framework wires the appropriate one in for each environment. Use it for transport handles, local signals, identity, or anything else that cannot round-trip through JSON. Never embedded in the SSR script block. The framework also forwards context to HMR hot-swaps so a replaced component keeps its env wiring.
isBrowser Both true in a browser environment, false in Node.js. Use to guard browser-only code that cannot go inside effect() , such as module-level expressions or computed() values.

Passing non-serializable runtime data via context

When a component needs runtime data the server cannot serialize (a live transport handle, an identity object, locally-created signals), pass an env bag as options.context . The framework forwards it to fn as the second argument. The server provides its own bag; the client provides its own; the two never cross the wire.

// shared/env.js. Two factories, same shape.
import { signal } from 'kensington';
export function makeServerEnv() {
  return { userId: 'ssr', userName: signal(''), transport: null };
}
export function makeClientEnv({ userId, transport }) {
  return { userId, userName: signal(''), transport };
}
// shared/app-page.js. Component takes (state, env).
import { t } from 'kensington';
export function appPage(state, env) {
  return t.main([
    t.span(env.userId),
    t.button({ onclick: () => env.transport?.reconnect() }, 'Reconnect'),
  ]);
}
// server.js
import { renderForHydration } from 'kensington';
import { makeServerEnv } from './shared/env.js';
import { appPage } from './shared/app-page.js';
const env = makeServerEnv();
renderForHydration(appPage, {}, 'appPage', { context: env });
// client.js
import { registerComponents } from 'kensington';
import { connectLive } from 'kensington/live';
import { makeClientEnv } from './shared/env.js';
import { appPage } from './shared/app-page.js';
const transport = connectLive({ /* ... */ });
const env = makeClientEnv({ userId: getTabId(), transport });
registerComponents({ appPage }, { context: env });

Avoid alternatives that solve the same problem in worse ways: closing over env in a wrapper at the renderForHydration call site (workable but awkward), setEnv / getEnv singletons (module-mutable state that races on concurrent SSR), or passing signals through state (they lose their methods through JSON.stringify and the framework warns).

Hydrated like button example Hydrated form validation example

Multi-client state. live signals

State shared across every connected browser. liveSignal(initial, name, options?) acts like signal() everywhere it is read but synchronizes through a server registry. Reads from one tab reflect writes from any tab. State persists across reloads and (with sqlite) across server restarts.

Best for state that partitions into small atomic values: cells in a spreadsheet, columns on a kanban board, presence per room. Direct .set(value) is last-write-wins; .set(fn) is atomic via compare-and-swap. Not suited to character-level concurrent text editing.

Setup. Three calls

Everything lives at the kensington/live subpath. Three setup calls cover the API. One per environment.

// One import covers the whole API.
import { liveSignal, connectLive, liveServer } from 'kensington/live';

1. Shared component file

The shared file runs on both server (SSR) and client. liveSignal(initial, name) returns a Signal<T> whose value is shared by name with every other connected client. Mix freely with regular signal() for local-only state and computed() for derivations.

// shared/todos.js
import { t, signal, computed } from 'kensington';
import { liveSignal } from 'kensington/live';
export function todos(state) {
  // Shared across all clients. Same instance everywhere.
  const items = liveSignal(state.items ?? [], 'todos:list');
  // Local. Not synced.
  const draft = signal('');
  // Derived. Recomputed locally; identical inputs -> identical outputs everywhere.
  const remaining = computed(() => items.get().filter(i => !i.done).length, 'remaining');
  function addTodo() {
    if (!draft.value.trim()) { return; }
    // Atomic read-modify-write via compare-and-swap. Concurrent calls converge.
    items.set(list => [...list, { id: crypto.randomUUID(), text: draft.value.trim(), done: false }]);
    draft.set('');
  }
  return t.div([
    t.p([remaining, ' remaining']),
    t.form({ onsubmit: e => { e.preventDefault(); addTodo(); } }, [
      t.input({ type: 'text', prop: { value: draft }, oninput: e => draft.set(e.target.value) }),
      t.button({ type: 'submit' }, 'Add'),
    ]),
    t.ul(items.mapWithKey('id', item => t.li(item.text))),
  ]);
}

2. Server

Create one liveServer at startup. It owns the registry, the persistence adapter, and the WebSocket multiplexer. live.attach(httpServer) mounts the WebSocket handler on a Node HTTP server at the path configured by the path option (default '/__kensington/live' ). The client's connectLive({ url }) defaults to the same path, so no extra configuration is needed unless you override one side. Persistence defaults to memory. Pass { kind: 'sqlite', path } for durability across restarts.

// server.js
import http from 'node:http';
import express from 'express';
import { renderForHydration } from 'kensington';
import { liveServer } from 'kensington/live';
import { todos } from './shared/todos.js';
const live = await liveServer({
  persistence: { kind: 'sqlite', path: './data/live.db' }, // or { kind: 'memory' }
});
const app = express();
app.get('/', (req, res) => {
  const state = { items: live.get('todos:list') ?? [] };
  res.send(renderForHydration(todos, state));
});
const server = http.createServer(app);
await live.attach(server);
server.listen(3000);

On Bun, the upgrade dance and the WebSocket handlers go through Bun's default-export object. Pass the request through data: { req } so onConnect(ws, req) can read headers off it.

// server.ts. Bun + Hono.
import { Hono } from 'hono';
import { renderForHydration } from 'kensington';
import { liveServer } from 'kensington/live';
import { todos } from './shared/todos.js';
const live = await liveServer({
  persistence: { kind: 'sqlite', path: './data/live.db' },
});
const app = new Hono();
app.get('/', c => {
  const state = { items: live.get('todos:list') ?? [] };
  return c.html(renderForHydration(todos, state));
});
export default {
  port: 3000,
  fetch(req, server) {
    if (new URL(req.url).pathname === '/__kensington/live' && server.upgrade(req, { data: { req } })) {
      return;
    }
    return app.fetch(req, { server });
  },
  websocket: live.bunWebsocket(),
};

3. Client

Open one WebSocket connection at boot, then register the same components as you would for a non-live SSR app. connectLive() with no arguments uses the same default path as liveServer ( '/__kensington/live' ). Pass url only if the server mounts at a different path or the WebSocket lives on a different host. The returned transport handle has a status field that is a reactive Signal<ConnectionStatus> you can read directly, transform, or pass into a component as data. The matching liveServer().status on the server is always 'connected' , so the same Signal type appears at both ends.

// client.js
import { registerComponents } from 'kensington';
import { connectLive } from 'kensington/live';
import { todos } from './shared/todos.js';
const live = connectLive();                   // defaults match liveServer
registerComponents({ todos });

Naming. The scoping mechanism

The runtime stays oblivious to URL, user, room, or document. The name string IS the scope. Common patterns:

Two calls to liveSignal with the same name in the same process return the same Signal instance. The name is the identity. Across files, modules, even across server-side effects and the shared component, identical names mean the same registry entry.

Persistence backend and per-signal policy

Two orthogonal decisions. The liveServer option selects WHERE persisted writes land. The per-signal persist option (default false ) decides WHICH signals use the backend.

liveServer persistence Behavior Cost
{ kind: 'memory' } Default. State held in process memory. Lost on restart. Fine for demos and tests. Zero deps.
{ kind: 'sqlite', path, flushInterval? } Backend stores values to a SQLite database. Writes are debounced (default 250ms) and grouped in a transaction. Reads come from an in-memory mirror loaded on startup. Requires better-sqlite3 (optional peer dep).

Per-signal persist on liveSignal(initial, name, { persist }) mirrors the persist flag on tag options. Default false. The cheap option is the default. persist: true is the explicit opt-in for archival behavior.

// Transient. Default. Lives in memory only. Server restart wipes it.
// Dropped from the server registry 30 seconds after the last subscriber leaves.
const cursor = liveSignal({ x: 0, y: 0 }, `cursor:user:${tabId}`);
// Persisted. Writes flow to the configured backend. The registry entry
// stays alive until an explicit live.delete(name).
const sticky = liveSignal({ x, y, text }, `sticky:${id}`, { persist: true });

First declaration wins. The policy is a property of the name, not of the call site. If one call passes { persist: true } and another passes { persist: false } for the same name, the first wins and a once-per-name warning fires.

Write policy. canRead and canWrite

Two layers. A global canWrite on liveServer applies to every client write. A per-signal canWrite on liveSignal({ canWrite }) applies only to that name. Both must allow. Each defaults to 'any' . Server-side writers ( live.set , server-side liveSignal.set ) bypass both checks.

type CanWrite =
  | 'any'                                                                // default. any authenticated client may write.
  | 'server-only'                                                        // no client may write. server writers only.
  | ((name: string, ctx: any, transition: { prev, next }) => boolean);   // custom predicate.

The function form gets the same ctx returned by onConnect plus the proposed transition. Use it to validate identity, value, or the relationship between the old and new value in one call.

const live = await liveServer({
  persistence: { kind: 'sqlite', path: './data/live.db' },
  onConnect: (ws, req) => ({ user: decodeSession(req.headers.cookie) }),
  canRead:  (name, ctx) => ctx.user != null,
  canWrite: (name, ctx) => ctx.user != null,           // global: must be authenticated
});
const currentBid = liveSignal(null, 'auction:current-bid', {
  persist: true,
  canWrite: (name, ctx, { prev, next }) => {           // per-signal: business rules
    if (next.userId !== ctx.user.id) { return false; }
    if (next.amount < (prev?.amount ?? 0) + MIN_INCREMENT) { return false; }
    if (prev?.userId === ctx.user.id) { return false; }
    return true;
  },
});
const bidHistory = liveSignal([], 'auction:bid-history', {
  persist: true,
  canWrite: 'server-only',                              // only server-side writers apply.
});

Don't use isBrowser inside canWrite . The predicate always runs on the server, where isBrowser is false . canWrite: !isBrowser evaluates to true and allows all client writes (the opposite of the intent). Use canWrite: 'server-only' for "no client can write."

Updates that depend on the current value

When the new value depends on the current value (counter increment, append to a list, toggle a flag, merge into an object), pass a function to .set instead of a value.

counter.set(n => n + 1);
viewers.set(prev => ({ users: [...prev.users, me] }));
reactions.set(prev => ({ ...prev, [me]: emoji }));
items.set(prev => prev.filter(it => it.id !== removedId));

Under concurrent writes from multiple clients, the function form converges. fn runs against the latest server value, so each client sees the others' updates instead of overwriting them. .set(value) on the same name races with last-write-wins.

Both .set(value) and .set(fn) return a Promise<void> that resolves once the server confirms or rejects with a structured LiveSetRejected Error on permanent failure ( canWrite denied, value not serializable, transport disconnected, retry cap exhausted). The server-authoritative value rolls back the local Signal before the rejection fires, so sig.value inside .catch already reflects the truth. Fire-and-forget callers can ignore the Promise; the library silences unhandled-rejection warnings for unawaited returns.

// Surface a rejection to the user via a toast.
try {
  await seat.set(myTabId);
} catch (err) {
  if (err instanceof Error && err.name === 'LiveSetRejected') {
    toast(`${err.signalName}: ${err.reason}. owned by ${err.authoritativeValue}`);
  }
}

When to use which form

Caveats

Connection status

The connection-status signal lives on the transport handles. Read connectLive().status on the client and liveServer().status on the server. Both are reactive Signal<ConnectionStatus> values yielding one of 'connecting' , 'connected' , 'reconnecting' , or 'disconnected' (the server is always 'connected' ).

A shared component renders the pill from a signal passed in as data. Wire it at both entry points so SSR shows the pill without a reflow on hydration.

// shared/status-pill.js
import { t } from 'kensington';
export function statusPill(status) {
  return t.span({ class: status.transform(s => `pill pill-${s}`, 'pill-class') }, status);
}
// server.js
const live = await liveServer({ /* ... */ });
res.send(renderForHydration(state => statusPill(live.status), state, 'statusPill'));
// client.js
const live = connectLive();
registerComponents({ statusPill: state => statusPill(live.status) });

Server-side liveSignal as a reactive subscription

Outside of renderForHydration , liveSignal(initial, name) on the server returns a long-lived Signal that subscribes to registry updates. Client writes, server-side live.set , and writes from other server-side liveSignal instances all propagate into the local Signal. Wrap an effect() around it to react.

import { effect } from 'kensington';
import { liveServer, liveSignal } from 'kensington/live';
const live = await liveServer({ persistence: { kind: 'sqlite', path: './data.db' } });
// Top-of-server boot. Outside any SSR call.
const counter = liveSignal(0, 'counter', { persist: true });
effect(() => {
  // Re-runs every time anyone writes to 'counter' (client or server).
  metrics.gauge('counter', counter.get());
  audit.log('counter changed', counter.get());
});

Same call shape on both sides. Inside renderForHydration , a fresh per-request Signal seeded from the registry. Outside SSR, a long-lived Signal that subscribes to registry updates.

The auto-unsubscribe trap

When the last local subscriber to a live signal goes away, the transport unsubscribes from the server. Usually correct (viewport virtualization, mount/unmount). But if the rendering chain that subscribes can drop transiently and the signal should stay subscribed for the component's lifetime, pin a persistent local subscriber:

import { effect, isBrowser } from 'kensington';
import { liveSignal } from 'kensington/live';
export function room(state) {
  const presence = liveSignal({ users: [] }, 'presence:list');
  const cursors  = liveSignal({}, 'cursors:everyone');
  let keepAlive = null;
  if (isBrowser) {
    keepAlive = effect(() => {
      presence.get();
      cursors.get();
    });
  }
  // ... rest of the component ...
  root.addDisconnectedCallback(() => {
    if (keepAlive !== null) { keepAlive.stop(); }
  });
  return root;
}

Use whenever the live signal's lifetime should equal the component's, not the rendering chain's. Presence, shared cursors, chat rooms, anywhere "stay subscribed even if nothing renders this right now" is the intended semantic.

Where liveSignals are created

Per-entity liveSignal instances (per-user cursors, per-cell raw values, per-document metadata) are typically read from multiple components. They must therefore be created outside any reactive callback. The general rule is Don't read a signal outside the scope where it was created ; the application to live signals is direct.

For live signals specifically, the first liveSignal(initial, name) call is what subscribes the name to the server. Create your live signals at the component scope, before the first render reads them, so every name is subscribed in time.

Best Practices

A few common mistakes and how to avoid them.

Use a signal for any value that needs to change after render

Attributes, content, and prop values are read once when the tag is built. A plain variable passed at that point is a snapshot. Changing it later has no effect on the DOM. Wrap the value in a signal so updates flow through automatically.

// Problem: the attribute is read once at creation. Changing the variable does nothing.
let submitting = false;
const btn = t.button({ disabled: submitting }, 'Submit').toElement();
submitting = true; // button is still enabled
// Fixed: the attribute updates whenever the signal changes.
const submitting = signal(false);
const btn = t.button({ disabled: submitting }, 'Submit').toElement();
submitting.set(true); // button becomes disabled

The same applies to text content ( t.p(mySignal) ) and prop values ( prop: { value: mySignal } ).

Pass a key to signals created inside a computed

A computed() or transform() callback re-runs every time its dependencies change. A bare signal() call inside the callback creates a brand-new instance on each re-run. Local interactive state resets to its initial value and the previous instance becomes a sleeping orphan in the devtools Signals tab.

Pass a stable key as the second argument to signal() to scope the signal to the surrounding computed . The same key returns the same instance across re-runs, so local state persists. Use the item identity (typically its id) as the key. The same applies inside mapWithKey 's mapFn since it wraps an internal computed.

// Works, but local state resets on every outer re-render. The library logs a
// console.warn pointing to the keyed form.
const list = items.mapWithKey('id', item => {
  const highlight = signal(false);
  return t.li({ class: highlight.transform(v => v ? 'on' : '') }, [
    t.button({ onclick: () => highlight.set(true) }, item.label),
  ]);
});
// Best: keyed signal. Same instance across re-runs, state persists, and the
// signal is stopped automatically when the item leaves the list.
const list = items.mapWithKey('id', item => {
  const highlight = signal(false, item.id);
  return t.li({ class: highlight.transform(v => v ? 'on' : '') }, [
    t.button({ onclick: () => highlight.set(true) }, item.label),
  ]);
});

For derived values that depend only on data already on the item, lifting the signal onto the item object is also a good choice. It avoids the key bookkeeping and makes the per-item state explicit in the data model.

// Alternative: store reactive state on the item itself.
function makeItem(id, label) {
  const done = signal(false);
  const cls = done.transform(d => d ? 'done' : 'open');
  return { id, label, done, cls };
}
const items = signal([makeItem(1, 'Buy milk'), makeItem(2, 'Walk dog')]);
const rows = items.mapWithKey('id', item => t.li({ class: item.cls }, item.label));

Don't read a signal outside the scope where it was created

A signal created inside a computed , effect , transform , or mapWithKey mapFn belongs to that reactive scope. Reading it from outside is the bug. The keyed-signal pattern above works precisely because per-row state is only read by tags built inside the row — same scope, same lifecycle.

The failure mode is subtle: the signal looks fine at first read, but when the surrounding callback re-runs without the key (the row disappears, the outer state changes), the keyed sweep stops the signal. Any external reader still holding a reference now subscribes to a dead signal. Kensington fires an out-of-scope-reactive-reference warning when it can detect this at read time.

// Wrong. The signal is created inside the mapFn (a reactive scope), but a
// header component elsewhere also reads it. When the row leaves the list,
// the signal stops; the header is left with a dead reference.
const rows = items.mapWithKey('id', item => {
  const expanded = signal(false, item.id);
  exposeExpandedFlag(item.id, expanded);   // ← reader outside the mapFn
  return t.li({ class: expanded.transform(v => v ? 'on' : '') }, item.label);
});
// Right. The signal needs to live longer than any single row, so create
// it outside any reactive scope. The mapFn just reads it.
const expandedFlags = new Map();   // module scope. Outside any callback.
for (const id of knownIds) { expandedFlags.set(id, signal(false)); }
const rows = items.mapWithKey('id', item => {
  const expanded = expandedFlags.get(item.id);   // lookup, not creation
  return t.li({ class: expanded.transform(v => v ? 'on' : '') }, item.label);
});

The diagnostic question. Where will this signal be read?

Same rule for liveSignal . Per-user cursors, per-cell raw values, per-document metadata are usually read by multiple components, so the signal needs to outlive any one reactive callback. Create the names outside the reactive scope before the first render that reads them.

Use a named function for event handlers that read mutable state

When you use mapWithKey , the mapFn runs once per key and the tag is cached. Event handlers attached inside the mapFn therefore close over whatever variables existed at first render. A named function defined outside the mapFn that reads module-level state at call time always sees the current value.

// Inline arrow: closes over 'mode' at first render. Cached, never updates.
let mode = 'view';
const rows = items.mapWithKey('id', item =>
  t.li({ onclick: () => handleClick(item.id, mode) }, item.label)
);
// Named function: reads mode at click time, so it always reflects the
// current value even though the tag itself is cached by mapWithKey.
let mode = 'view';
function handleClick(e) { doSomething(e.currentTarget.dataset.id, mode); }
const rows = items.mapWithKey('id', item =>
  t.li({ data: { id: item.id }, onclick: handleClick }, item.label)
);
mode = 'edit'; // all items see 'edit' when clicked, no re-render needed

Use mapWithKey for lists that may change

Without a key, every re-render builds fresh DOM nodes for every item. With mapWithKey , the mapFn runs once per id and the tag is cached. Reorders, additions, and removals reuse existing DOM nodes; only new items pay for tag construction.

// Problem: every item rebuilds on every update.
const rows = items.transform(list => list.map(item => t.li(item.label)));
// Fixed: nodes are reused. Only added items pay for construction.
const rows = items.mapWithKey('id', item => t.li(item.label));

Devtools

Kensington ships a devtools overlay for inspecting signals, computed signals, effects, and DOM bindings at runtime. It is a floating panel that can be toggled with a button in the bottom-right corner of the page. A pop-out button (↗) in the panel header opens it in a separate window so it can sit alongside the page being developed. The popup reconnects automatically when the main page reloads.

Setup

Import kensington/devtools in your dev entry point. It mounts the panel overlay in one step. Wrap it in your bundler's dev-only guard so it tree-shakes out of production builds.

// Vite
if (import.meta.env.DEV) {
  await import('kensington/devtools');
}
// webpack / esbuild / Parcel
if (process.env.NODE_ENV !== 'production') {
  await import('kensington/devtools');
}
// No-build (plain script tags, import maps)
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
  await import('kensington/devtools');
}

The import is safe in non-browser environments. It checks for window before mounting and does nothing on the server.

Panel

The panel has five tabs.

The filter input in each tab narrows rows by ID, value, label, or state. Hovering a signal row highlights its bound elements on the page with a temporary outline.

Known tradeoffs

These are deliberate simplicity choices, not bugs.

GitHub

Examples

String rendering

Server-side rendering

Tag objects convert to strings automatically in template literals and string concatenation. Call .toString() explicitly when passing to a function like res.send() , which won't coerce the argument otherwise.

import express from 'express';
import { t } from 'kensington';
function layout(title, content) {
  return t.htmlWithDocType({ lang: 'en' }, [
    t.head([
      t.meta({ charset: 'utf-8' }),
      t.title(title),
      t.link({ rel: 'stylesheet', href: '/style.css' }),
    ]),
    t.body(t.main({ class: 'container' }, content)),
  ]).toString();
}
function usersPage(users) {
  return [
    t.h1('Users'),
    t.table([
      t.thead(t.tr(['Name', 'Role'].map(h => t.th(h)))),
      t.tbody(users.map(u =>
        t.tr([t.td(u.name), t.td(u.role)])
      )),
    ]),
  ];
}
const app = express();
app.get('/users', async (req, res) => {
  const users = await db.getUsers();
  res.send(layout('Users', usersPage(users)));
});

Framework integration

Kensington works with any Node.js HTTP framework. The pattern is the same everywhere: build your HTML with Kensington, call .toString() , and pass the string to the framework's response method.

Hono
import { Hono } from 'hono';
import { t } from 'kensington';
const app = new Hono();
app.get('/users', async (c) => {
  const users = await db.getUsers();
  return c.html(layout('Users', usersPage(users)));
});
Fastify
import Fastify from 'fastify';
import { t } from 'kensington';
const app = Fastify();
app.get('/users', async (req, reply) => {
  const users = await db.getUsers();
  reply
    .header('content-type', 'text/html; charset=utf-8')
    .send(layout('Users', usersPage(users)));
});

Hono's c.html() sets the content-type header automatically. For frameworks that don't have a dedicated HTML method, set Content-Type: text/html; charset=utf-8 manually as shown in the Fastify example.

Express render helper

Attach a res.renderKensington helper via middleware so routes never call .toString() directly and the layout is applied in one place.

middleware/render.js
import { layout } from './layout.js';
export function renderMiddleware(req, res, next) {
  res.renderKensington = (pageFunc, ...args) => {
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    res.send(layout(pageFunc(...args)).toString());
  };
  next();
}
server.js
import express from 'express';
import { homePage, usersPage } from './pages.js';
import { renderMiddleware } from './middleware/render.js';
const app = express();
app.use(renderMiddleware);
app.get('/', (req, res) => {
  res.renderKensington(homePage, { title: 'Home' });
});
app.get('/users', async (req, res) => {
  const users = await db.getUsers();
  res.renderKensington(usersPage, { title: 'Users', users });
});

kensington-express

kensington-express is an Express middleware that attaches res.renderView() to each response. It applies a default layout, merges locals, and sets the content-type header automatically.

npm install kensington-express
views/layout.js
import { t } from 'kensington';
export default function layout(locals, page) {
  return t.htmlWithDocType({ lang: 'en' }, [
    t.head([
      t.meta({ charset: 'utf-8' }),
      t.title(locals.title),
      t.link({ rel: 'stylesheet', href: '/style.css' }),
    ]),
    t.body(page(locals)),
  ]);
}
views/home.js
import { t } from 'kensington';
export default function homePage({ title, items }) {
  return t.main([
    t.h1(title),
    t.ul(items.map(item => t.li(item))),
  ]);
}
// app.js
import express from 'express';
import kensingtonView from 'kensington-express';
import layout from './views/layout.js';
import homePage from './views/home.js';
const app = express();
app.use(kensingtonView(layout));
app.get('/', (req, res) => {
  res.renderView(homePage, { title: 'Home', items: ['foo', 'bar'] });
});

Locals passed to renderView are merged with req.route , app.locals , and res.locals (later values win). To use a different layout for one route, pass it as layout in the options object. Pass layout: null to skip the layout entirely, which is useful for returning bare HTML fragments for htmx swap targets.

// Alternate layout for one route
app.get('/admin', (req, res) => {
  res.renderView(adminPage, { layout: adminLayout, title: 'Admin' });
});
// No layout (bare fragment)
app.get('/fragment', (req, res) => {
  res.renderView(myFragment, { layout: null });
});

kensington-fastify

kensington-fastify is a Fastify plugin that attaches reply.renderView() and decorates each reply with reply.locals for per-request data.

npm install kensington-fastify
// server.js
import Fastify from 'fastify';
import kensingtonView from 'kensington-fastify';
import layout from './views/layout.js';
import homePage from './views/home.js';
const fastify = Fastify();
await fastify.register(kensingtonView, {
  defaultLayout: layout,
  defaultContext: { appName: 'My App' },
});
fastify.get('/', async (request, reply) => {
  reply.renderView(homePage, { title: 'Home', items: ['foo', 'bar'] });
});

Locals are merged in this order (later values win): defaultContext , reply.locals , options passed to renderView . Use reply.locals in a hook to attach per-request data without passing it to every renderView call.

// Attach the current user in a hook. Available in every page renderer
fastify.addHook('preHandler', async (request, reply) => {
  reply.locals.user = await getUserFromSession(request);
});
fastify.get('/', async (request, reply) => {
  reply.renderView(homePage, { title: 'Home' });
  // locals available to the renderer: { appName, user, title }
});

To use an alternate layout or skip the layout for one route, pass layout in the options object. Pass layout: null for bare HTML fragments.

// Alternate layout
fastify.get('/admin', async (request, reply) => {
  reply.renderView(adminPage, { layout: adminLayout, title: 'Admin' });
});
// No layout (bare fragment, e.g. for htmx)
fastify.get('/fragment', async (request, reply) => {
  reply.renderView(myFragment, { layout: null });
});

Form from schema

Build forms from a field definition array using a helper function.

JavaScript
const fields = [
  { name: 'email',    type: 'email',    label: 'Email',    required: true },
  { name: 'password', type: 'password', label: 'Password', required: true },
  { name: 'remember', type: 'checkbox', label: 'Remember me' },
];
function formField({ name, type, label, required }) {
  return t.div({ class: 'field' }, [
    t.label({ for: name }, label),
    t.input({ id: name, name, type, required }),
  ]);
}
t.form({ action: '/login', method: 'post' }, [
  ...fields.map(formField),
  t.button({ type: 'submit' }, 'Log in'),
]);
HTML output
<form action="/login" method="post">
  <div class="field">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required>
  </div>
  <div class="field">
    <label for="password">Password</label>
    <input id="password" name="password" type="password" required>
  </div>
  <div class="field">
    <label for="remember">Remember me</label>
    <input id="remember" name="remember" type="checkbox">
  </div>
  <button type="submit">Log in</button>
</form>

Preformatted blocks

script , style , pre , and textarea join content arrays with newlines and skip indentation, so string content is inserted without modification.

JavaScript
t.style([
  'body { margin: 0; }',
  'h1 { color: steelblue; }',
]);
t.script(`
  const el = document.getElementById("app");
  el.textContent = "Hello";
`);
HTML output
<style>
body { margin: 0; }
h1 { color: steelblue; }
</style>
<script>
  const el = document.getElementById("app");
  el.textContent = "Hello";
</script>

Reactive data

Counter

A basic counter using signal , computed , and effect . Multiple synchronous set() calls batch into a single DOM update.

import { t, signal, computed, effect } from 'kensington';
const count = signal(0);
const label = computed(() => count.get() === 1 ? 'click' : 'clicks');
effect(() => {
  document.title = `${count.get()} ${label.get()}`;
});
const app = t.div({ class: 'counter' }, [
  t.p([count, ' ', label]),
  t.button({ type: 'button', onclick: () => count.set(n => n + 1) }, '+'),
  t.button({ type: 'button', onclick: () => count.set(n => n - 1) }, '-'),
  t.button({ type: 'button', onclick: () => count.set(0) }, 'Reset'),
]);
document.body.append(app.toElement());

Live filter

A signal holds the search query. A computed signal derives the visible rows. Passing the computed signal as content means the table body updates automatically as the user types, with no manual DOM writes needed.

import { t, signal, computed } from 'kensington';
const people = [
  { name: 'Alice', role: 'Admin'  },
  { name: 'Bob',   role: 'Editor' },
  { name: 'Carol', role: 'Viewer' },
  { name: 'Dave',  role: 'Editor' },
  { name: 'Eve',   role: 'Admin'  },
];
const query = signal('');
const filtered = computed(() => {
  const q = query.get().toLowerCase();
  if (!q) { return people; }
  return people.filter(p =>
    p.name.toLowerCase().includes(q) || p.role.toLowerCase().includes(q),
  );
});
document.body.append(
  t.div([
    t.input({
      type: 'search',
      placeholder: 'Filter by name or role...',
      oninput: e => query.set(e.target.value),
    }),
    t.table([
      t.thead(t.tr([t.th('Name'), t.th('Role')])),
      t.tbody(filtered.mapWithKey('name', p =>
        t.tr([t.td(p.name), t.td(p.role)]),
      )),
    ]),
  ]).toElement()
);

Todo list

A signal holds the todo array. signal.mapWithKey('id', mapFn) returns a signal of rendered list items. The mapFn runs once per id the first time the item is seen; the resulting tag is cached and reused on every subsequent render. Adding or removing items reorders the existing DOM nodes rather than rebuilding them.

import { t, signal } from 'kensington';
let nextId = 1;
const todos = signal([
  { id: nextId++, text: 'Buy groceries', done: signal(false) },
]);
function addTodo(text) {
  todos.set(list => [...list, { id: nextId++, text, done: signal(false) }]);
}
function removeTodo(id) {
  todos.set(list => list.filter(item => item.id !== id));
}
const input = t.input({ type: 'text', placeholder: 'New item...' });
document.body.append(
  t.div([
    t.div([
      input,
      t.button({
        type: 'button',
        onclick: () => {
          const el = input.getDomElement();
          if (el?.value.trim()) { addTodo(el.value.trim()); el.value = ''; }
        },
      }, 'Add'),
    ]),
    t.ul(todos.mapWithKey('id', item =>
      t.li([
        t.span({
          style: { textDecoration: item.done.transform(d => d ? 'line-through' : 'none') },
        }, item.text),
        t.button({ type: 'button', onclick: () => item.done.set(d => !d) }, 'Done'),
        t.button({ type: 'button', onclick: () => removeTodo(item.id) }, 'Remove'),
      ])
    )),
  ]).toElement()
);

Editable list rows

Each row has its own edit mode that does not belong on the outer data array. signal(false, item.id) scopes the per-row state to the surrounding computed so the same signal instance is reused across renders. The inner computed(() => editing.get() ? input : label, item.id) swaps the visible element when editing toggles. Keying it gives the inner computed a stable Signal identity across outer re-runs and lets it react to editing independently of changes to the items array. Both are stopped automatically when their row leaves the list.

See Per-item local state and derived values for a full description of the keyed signal and keyed computed patterns.

import { t, signal, computed } from 'kensington';
const items = signal([
  { id: 1, label: 'Apples' },
  { id: 2, label: 'Bananas' },
  { id: 3, label: 'Cherries' },
]);
function rename(id, label) {
  items.set(list => list.map(it => it.id === id ? { ...it, label } : it));
}
document.body.append(t.ul(items.mapWithKey('id', item => {
  // Keyed signal scoped to mapWithKey's internal computed. Same instance per id.
  const editing = signal(false, item.id);
  const input = t.input({
    type: 'text',
    value: item.label,
    onblur: e => { rename(item.id, e.target.value); editing.set(false); },
  });
  const label = t.span({ onclick: () => editing.set(true) }, item.label);
  return t.li([
    computed(() => editing.get() ? input : label, item.id),
  ]);
})).toElement());

Click a row to edit, blur to save. Adding or removing items elsewhere in the list does not collapse a row that is currently being edited, because each row's editing signal kept its identity across the re-render.

Dark mode

effect is the right tool when a signal needs to drive something outside the reactive tree. Here it toggles a class on document.documentElement . The button label is a computed that flips with the signal.

import { t, signal, computed, effect } from 'kensington';
const dark = signal(matchMedia('(prefers-color-scheme: dark)').matches);
effect(() => {
  document.documentElement.classList.toggle('dark', dark.get());
});
const label = computed(() => dark.get() ? 'Light mode' : 'Dark mode');
document.body.append(
  t.button({ type: 'button', onclick: () => dark.set(v => !v) }, label).toElement()
);

Character counter

.transform() derives the CSS class directly from the remaining count. Passing the remaining signal as content means the number updates in place without replacing surrounding text nodes.

import { t, signal, computed } from 'kensington';
const MAX = 280;
const text = signal('');
const remaining = computed(() => MAX - text.get().length);
document.body.append(
  t.div([
    t.textarea({
      rows: 4,
      placeholder: 'Type something...',
      oninput: e => text.set(e.target.value),
    }),
    t.p({
      class: remaining.transform(n => n < 0 ? 'counter counter--over' : 'counter'),
    }, [remaining, ' characters remaining']),
  ]).toElement()
);

Sortable table

Two signals, sort column and sort direction, drive both the data rows and the column headers. A computed produces the sorted list and signal.mapWithKey renders one <tr> per person. The mapper runs once per name; on every sort the same <tr> instances are reordered rather than rebuilt. Each header has its own computed that tracks only the signals it actually reads. The active header tracks both, inactive headers track only sortCol . Stale subscriptions are cleaned up automatically between runs.

import { t, signal, computed } from 'kensington';
const people = [
  { name: 'Alice', age: 32, role: 'Admin'  },
  { name: 'Bob',   age: 28, role: 'Editor' },
  { name: 'Carol', age: 41, role: 'Viewer' },
  { name: 'Dave',  age: 25, role: 'Editor' },
];
const sortCol = signal('name');
const sortAsc = signal(true);
function compare(a, b, col) {
  const av = a[col];
  const bv = b[col];
  if (typeof av === 'number' && typeof bv === 'number') {
    return av - bv;
  }
  return String(av).localeCompare(String(bv));
}
// A computed sorted list. mapWithKey runs the row builder once per person and
// reuses the same <tr> instances on every sort. Sorting reorders existing DOM
// nodes rather than rebuilding them.
const sorted = computed(() => {
  const col = sortCol.get();
  const asc = sortAsc.get();
  return [...people].sort((a, b) => asc ? compare(a, b, col) : -compare(a, b, col));
});
function sortHeader(col, label) {
  // Active column subscribes to both sortCol and sortAsc. Inactive columns subscribe
  // only to sortCol, because sortAsc.get() is never reached. Stale subscriptions are
  // cleaned up automatically between runs.
  const heading = computed(() => sortCol.get() === col
    ? `${label} ${sortAsc.get() ? '↑' : '↓'}`
    : label);
  return t.th({
    style: { cursor: 'pointer' },
    onclick: () => {
      if (sortCol.get() === col) {
        sortAsc.set(v => !v);
      } else {
        sortCol.set(col);
        sortAsc.set(true);
      }
    },
  }, heading);
}
document.body.append(
  t.table([
    t.thead(t.tr([
      sortHeader('name', 'Name'),
      sortHeader('age', 'Age'),
      sortHeader('role', 'Role'),
    ])),
    t.tbody(sorted.mapWithKey('name', p =>
      t.tr([t.td(p.name), t.td(String(p.age)), t.td(p.role)]),
    )),
  ]).toElement()
);

Static HTML tab switcher

When the page is mostly static HTML, a signal and a few effect() calls are enough to add interactivity without rebuilding the markup with Kensington. Here a signal holds the active tab key, and each tab button and content panel reads the signal in its own effect to update its class. The initial active tab is read from the HTML itself so the page works before JavaScript runs.

HTML
<nav class="tabs">
  <button class="tab tab--active" data-tab="overview">Overview</button>
  <button class="tab" data-tab="install">Install</button>
  <button class="tab" data-tab="api">API</button>
</nav>
<div class="panel" data-panel="overview">Overview content...</div>
<div class="panel panel--hidden" data-panel="install">Install content...</div>
<div class="panel panel--hidden" data-panel="api">API content...</div>
JavaScript
import { signal, effect } from 'kensington';
// Read the initial active tab from the DOM so the page is valid before JS runs.
const activeTab = signal(
  document.querySelector('.tab--active')?.dataset.tab ?? 'overview'
);
document.querySelectorAll('[data-tab]').forEach(btn => {
  btn.addEventListener('click', () => activeTab.set(btn.dataset.tab));
  effect(() => {
    btn.classList.toggle('tab--active', btn.dataset.tab === activeTab.get());
  });
});
document.querySelectorAll('[data-panel]').forEach(panel => {
  effect(() => {
    panel.classList.toggle('panel--hidden', panel.dataset.panel !== activeTab.get());
  });
});

Static HTML accordion

Each accordion item gets its own signal , created from its initial aria-expanded attribute. An effect keeps the attribute and the hidden property on the panel in sync as the signal changes. The pattern scales to any number of items with no shared state.

HTML
<div class="accordion">
  <button class="accordion-toggle"
    aria-expanded="false"
    aria-controls="panel-1">What is Kensington?</button>
  <div id="panel-1" class="accordion-panel" hidden>
    An HTML library for Node and the browser.
  </div>
</div>
<div class="accordion">
  <button class="accordion-toggle"
    aria-expanded="true"
    aria-controls="panel-2">Does it require a build step?</button>
  <div id="panel-2" class="accordion-panel">
    No. Import it directly from npm or a CDN.
  </div>
</div>
JavaScript
import { signal, effect } from 'kensington';
document.querySelectorAll('.accordion-toggle').forEach(btn => {
  const panel = document.getElementById(btn.getAttribute('aria-controls'));
  const open = signal(btn.getAttribute('aria-expanded') === 'true');
  btn.addEventListener('click', () => open.set(v => !v));
  effect(() => {
    const isOpen = open.get();
    btn.setAttribute('aria-expanded', String(isOpen));
    panel.hidden = !isOpen;
  });
});

Hydrated component

A like button rendered on the server with real data, then picked up on the client as a live reactive component. The component function is identical in both environments: renderForHydration embeds the initial state and registerComponents mounts it reactively. The click handler applies an optimistic update and reverts if the request fails.

// components/like-button.js
import { t, signal } from 'kensington';
export function likeButton({ postId, likeCount, userLiked }) {
  const likes = signal(likeCount);
  const liked = signal(userLiked);
  function toggle() {
    const next = !liked.get();
    liked.set(next);
    likes.set(n => n + (next ? 1 : -1));
    fetch(`/api/posts/${postId}/like`, { method: next ? 'POST' : 'DELETE' })
      .catch(() => {
        liked.set(!next);
        likes.set(n => n + (next ? -1 : 1));
      });
  }
  return t.button({
    type: 'button',
    class: liked.transform(v => v ? 'like-btn like-btn--active' : 'like-btn'),
    ariaPressed: liked.transform(String),
    onclick: toggle,
  }, [t.span({ ariaHidden: 'true' }, '♥'), ' ', likes]);
}
server.js
import { renderForHydration, t } from 'kensington';
import { likeButton } from './components/like-button.js';
app.get('/posts/:id', async (req, res) => {
  const post = await db.getPost(req.params.id);
  const userLiked = await db.hasLiked(req.user?.id, post.id);
  res.send(
    t.htmlWithDocType({ lang: 'en' }, [
      t.head([
        t.meta({ charset: 'utf-8' }),
        t.title(post.title),
        t.script({ src: '/client.js', type: 'module' }),
      ]),
      t.body(
        t.article([
          t.h1(post.title),
          renderForHydration(likeButton, {
            postId: post.id,
            likeCount: post.likeCount,
            userLiked,
          }),
        ])
      ),
    ]).toString()
  );
});
client.js
import { registerComponents } from 'kensington';
import { likeButton } from './components/like-button.js';
registerComponents({ likeButton });

Form with server-side validation

The form is rendered on the server with renderForHydration and mounted as a reactive component on the client. Submitting calls fetch with the form data as JSON. On validation failure the server returns { errors } and the errors signal updates, reactively showing each message and adding an error class to the affected field. Input values are preserved because the form element stays in place. On success the server returns { success: true } and the client navigates away.

// components/registration-form.js
import { t, signal } from 'kensington';
export function registrationForm() {
  const errors = signal({});
  async function submit(e) {
    e.preventDefault();
    const body = Object.fromEntries(new FormData(e.target));
    const res = await fetch('/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data = await res.json();
    if (data.errors) {
      errors.set(data.errors);
    } else {
      window.location = '/register/success';
    }
  }
  return t.form({ class: 'form', onsubmit: submit }, [
    formField('name',     'Full name', 'text',     errors),
    formField('email',    'Email',     'email',    errors),
    formField('password', 'Password',  'password', errors),
    t.button({ type: 'submit' }, 'Create account'),
  ]);
}
function formField(name, label, type, errors) {
  const error = errors.transform(e => e[name]);
  return t.div({
    class: error.transform(e => e ? 'field field--error' : 'field'),
  }, [
    t.label({ for: name }, label),
    t.input({ id: name, name, type }),
    error.transform(e => e ? t.p({ class: 'field-error' }, e) : null),
  ]);
}
server.js
import { renderForHydration, t } from 'kensington';
import { registrationForm } from './components/registration-form.js';
app.use(express.json());
app.get('/register', (req, res) => {
  res.send(layout('Register', renderForHydration(registrationForm, {})));
});
app.post('/register', async (req, res) => {
  const { name, email, password } = req.body;
  const errors = {};
  if (!name?.trim())
    errors.name = 'Name is required.';
  if (!email?.includes('@'))
    errors.email = 'Enter a valid email address.';
  if ((password?.length ?? 0) < 8)
    errors.password = 'Password must be at least 8 characters.';
  if (Object.keys(errors).length) {
    return res.json({ errors });
  }
  await db.createUser({ name, email, password });
  res.json({ success: true });
});
client.js
import { registerComponents } from 'kensington';
import { registrationForm } from './components/registration-form.js';
registerComponents({ registrationForm });

Lifecycle widget

A polling component that uses addConnectedCallback to start a data fetch loop when mounted, and addDisconnectedCallback to stop it when removed. persist: true keeps the element's signal effects paused rather than destroyed on DOM removal, so the element can be re-inserted and resume reactivity. The connected and disconnected callbacks re-fire on each cycle as part of that mechanism.

import { t, signal } from 'kensington';
function PriceTicker({ symbol }) {
  const price = signal('--');
  const direction = signal(0);
  let prevPrice = null;
  let pollId = null;
  const ticker = t.div(
    { class: 'ticker', persist: true },
    [
      t.span({ class: 'symbol' }, symbol),
      t.span({ class: 'price' }, price),
      t.span(
        { class: direction.transform(d => d > 0 ? 'up' : d < 0 ? 'down' : 'flat') },
        direction.transform(d => d > 0 ? '▲' : d < 0 ? '▼' : '–'),
      ),
    ],
  );
  ticker.addConnectedCallback(function() {
    async function poll() {
      const res = await fetch(`/api/price/${symbol}`);
      const { price: p } = await res.json();
      if (prevPrice !== null) { direction.set(Math.sign(p - prevPrice)); }
      price.set(p.toFixed(2));
      prevPrice = p;
    }
    poll();
    pollId = setInterval(poll, 5000);
  });
  ticker.addDisconnectedCallback(() => {
    clearInterval(pollId);
  });
  return ticker.toElement();
}

Effect pause and resume

effect() returns an object with stop() and resume() . stop() unsubscribes the effect from all signals so it stops reacting to changes. resume() re-runs the callback and re-establishes subscriptions. Together they let you pause and restart a single effect object without creating a new one on every cycle.

The natural home for this is a hand-written web component. The render effect is created once in the constructor and started stopped. connectedCallback resumes it; disconnectedCallback stops it again so signal updates do not fire against a detached element.

import { signal, effect } from 'kensington';
class LiveClock extends HTMLElement {
  #time = signal('');
  #tickId = null;
  #render;
  constructor() {
    super();
    this.#render = effect(() => {
      this.textContent = this.#time.get();
    });
    this.#render.pause(); // start paused; do not render until connected
  }
  connectedCallback() {
    this.#render.resume();
    const tick = () => this.#time.set(new Date().toLocaleTimeString());
    tick();
    this.#tickId = setInterval(tick, 1000);
  }
  disconnectedCallback() {
    this.#render.pause();
    clearInterval(this.#tickId);
  }
}
customElements.define('live-clock', LiveClock);

The effect is defined once, created once, and reused across every connection cycle. Without resume() you would call effect(...) again inside connectedCallback on every reconnection, discarding the previous effect object each time.

Single-page app router

A minimal client-side router built on history.pushState and the popstate event. The current route is held in a signal so any effect or computed that reads it re-runs automatically when the URL changes.

import { t, signal, effect } from 'kensington';
function parseRoute() {
  const [path, search] = window.location.pathname.split('?');
  const params = Object.fromEntries(new URLSearchParams(search));
  const segments = path.split('/').filter(Boolean);
  return { path, segments, params };
}
const route = signal(parseRoute());
function navigate(path) {
  history.pushState(null, '', path);
  route.set(parseRoute());
}
window.addEventListener('popstate', () => route.set(parseRoute()));
// Intercept same-origin <a> clicks so internal links do not cause full reloads.
document.addEventListener('click', e => {
  const a = e.target.closest('a[href]');
  if (!a || a.origin !== location.origin || a.hasAttribute('download')) return;
  e.preventDefault();
  navigate(a.pathname + a.search);
});
const app = document.getElementById('app');
effect(() => {
  const { path } = route.get();
  let view;
  if (path === '/') {
    view = homePage();
  } else if (path.startsWith('/user/')) {
    const id = path.split('/')[2];
    view = userPage(id);
  } else {
    view = notFound();
  }
  app.replaceChildren(view.toElement());
});
function homePage() {
  return t.main([
    t.h1('Home'),
    t.nav([
      t.a({ href: '/user/1' }, 'User 1'),
      ' ',
      t.a({ href: '/user/2' }, 'User 2'),
    ]),
  ]);
}
function userPage(id) {
  return t.main([
    t.h1(`User ${id}`),
    t.a({ href: '/' }, 'Back'),
  ]);
}
function notFound() {
  return t.main(t.h1('404 - Not found'));
}

The click interceptor is the part most often omitted. Without it, internal links trigger a full page reload even with pushState in place. The a.origin !== location.origin check lets external links and target="_blank" links through unmodified.

"Missing" features

These patterns from React have no direct equivalent in Kensington, but can be built in a few lines on top of signal and effect .

createContext

React's createContext / useContext pattern can be built on top of a signal stack. Components call context.get() during synchronous construction to get the nearest provider's signal. provide(value, fn) wraps the value in a new signal, pushes it onto the stack, calls fn() to build the subtree, then pops. Consumers hold the signal reference after construction and update reactively through the normal signal subscription mechanism.

// create-context.js
import { signal } from 'kensington';
function createContext(defaultValue) {
  // each nested .provide call pushes a new value onto the stack at the beginning of the content block
  // and pops it off at the end of the content block
  const _stack = [signal(defaultValue)];
  return {
    get() {
      return _stack.at(-1);
    },
    provide(value, fn) {
      const ctx = signal(value);
      _stack.push(ctx);
      try {
        return fn(ctx);
      } finally {
        _stack.pop();
      }
    },
    set(val) {
      return this.get().set(val);
    },
  };
}
import { t } from 'kensington';
import { createContext } from './create-context.js';
const ThemeContext = createContext('light');
const UserContext = createContext({ name: 'Guest', role: 'viewer' });
function themeCard(title) {
  const theme = ThemeContext.get(); // signal reference captured at construction time; stays reactive
  return t.div({ class: theme.transform(v => `card card--${v}`) }, [
    t.strong(title),
    t.small(['theme: ', theme]),
  ]);
}
function userBadge() {
  const user = UserContext.get();
  return t.span(user.transform(u => `${u.name} (${u.role})`));
}
const app = t.div([
  t.button({
    type: 'button',
    onclick: () => ThemeContext.set(v => v === 'light' ? 'dark' : 'light'),
  }, 'Toggle theme'),
  t.button({
    type: 'button',
    onclick: () => UserContext.set(u => {
      const alice = { name: 'Alice', role: 'admin' };
      const guest = { name: 'Guest', role: 'viewer' };
      return u.name === 'Guest' ? alice : guest;
    }),
  }, 'Toggle login'),
  // No provider. Reads from the default signals.
  t.section([userBadge(), themeCard('Default')]),
  // Static provide. Always dark regardless of the toggle.
  ThemeContext.provide('dark', () =>
    t.section([userBadge(), themeCard('Always dark')]),
  ),
  // User overridden. The login toggle does not affect this subtree.
  UserContext.provide({ name: 'Bob', role: 'editor' }, () =>
    t.section([userBadge(), themeCard('Bob is always the user here')]),
  ),
]);
document.body.append(app.toElement());

useReducer

useReducer centralises state transitions behind a dispatch function. Wrap signal.set with a reducer to get the same pattern: complex state machines stay readable and the call sites only send action objects.

// use-reducer.js
import { signal } from 'kensington';
function useReducer(reducer, initialState) {
  const state = signal(initialState);
  function dispatch(action) {
    state.set(s => reducer(s, action)); // updater form: reducer always sees the latest state
  }
  return { state, dispatch };
}
import { t } from 'kensington';
import { useReducer } from './use-reducer.js';
function cartReducer(state, action) {
  switch (action.type) {
    case 'add':
      return { items: [...state.items, action.item], total: state.total + action.item.price };
    case 'remove': {
      const item = state.items.find(i => i.id === action.id);
      return { items: state.items.filter(i => i.id !== action.id), total: state.total - item.price };
    }
    case 'clear':
      return { items: [], total: 0 };
    default:
      return state;
  }
}
const { state, dispatch } = useReducer(cartReducer, { items: [], total: 0 });
const products = [
  { id: 1, name: 'Widget',    price: 9.99  },
  { id: 2, name: 'Gadget',    price: 24.99 },
  { id: 3, name: 'Doohickey', price: 4.99  },
];
document.body.append(
  t.div([
    t.h2('Shop'),
    t.ul(products.map(p =>
      t.li([p.name, ' . ', t.button({ type: 'button', onclick: () => dispatch({ type: 'add', item: p }) }, 'Add')])
    )),
    t.h2('Cart'),
    t.ul(state.transform(s =>
      s.items.map(item =>
        t.li([
          item.name,
          ' ',
          t.button({ type: 'button', onclick: () => dispatch({ type: 'remove', id: item.id }) }, 'Remove'),
        ])
      )
    )),
    t.p(state.transform(s => `Total: $${s.total.toFixed(2)}`)),
    t.button({ type: 'button', onclick: () => dispatch({ type: 'clear' }) }, 'Clear cart'),
  ]).toElement()
);

useLocalStorage

A signal that reads its initial value from localStorage and writes back on every change. The effect handles the sync. The rest of your code just reads and sets the signal normally. Guard the initial read with isBrowser so server-rendered components do not throw.

// use-local-storage.js
import { signal, effect, isBrowser } from 'kensington';
function useLocalStorage(key, defaultValue) {
  const stored = isBrowser ? localStorage.getItem(key) : null;
  const s = signal(stored !== null ? JSON.parse(stored) : defaultValue); // !== null: stored could be '0', 'false', etc.
  effect(() => {
    localStorage.setItem(key, JSON.stringify(s.get()));
  });
  return s;
}
import { t } from 'kensington';
import { useLocalStorage } from './use-local-storage.js';
const theme = useLocalStorage('theme', 'light');
document.body.append(
  t.div([
    t.p(['Current theme: ', theme]),
    t.button({
      type: 'button',
      onclick: () => theme.set(v => v === 'light' ? 'dark' : 'light'),
    }, theme.transform(v => `Switch to ${v === 'light' ? 'dark' : 'light'} mode`)),
  ]).toElement()
);

useDebounce

Returns a derived signal that only updates after the source has been stable for delay milliseconds. Each time the source changes, the pending timeout is cleared and restarted. Because effect does not support a cleanup return value, the timeout ID lives in the enclosing closure.

// use-debounce.js
import { signal, effect } from 'kensington';
function useDebounce(source, delay) {
  const debounced = signal(source.get());
  let id;
  effect(() => {
    const value = source.get();
    clearTimeout(id);
    id = setTimeout(() => debounced.set(value), delay);
  });
  return debounced;
}
import { signal, effect, t } from 'kensington';
import { useDebounce } from './use-debounce.js';
const query    = signal('');
const debounced = useDebounce(query, 300);
const results  = signal([]);
// fetch fires only after the user pauses, not on every keystroke
effect(() => {
  const q = debounced.get();
  if (!q) { results.set([]); return; }
  fetch(`/api/search?q=${encodeURIComponent(q)}`)
    .then(r => r.json())
    .then(data => results.set(data));
});
document.body.append(
  t.div([
    t.input({
      type: 'search',
      placeholder: 'Search...',
      oninput: e => query.set(e.target.value),
    }),
    t.ul(results.transform(items => items.map(r => t.li(r)))),
  ]).toElement()
);

useFetch

Returns { data, loading, error } signals that update as the request progresses. When the URL signal changes, the in-flight request is aborted via AbortController before the new one starts. The abort controller lives in the closure for the same reason as the debounce timeout -- effect does not support a cleanup return value.

// use-fetch.js
import { signal, effect } from 'kensington';
function useFetch(urlSignal) {
  const data    = signal(null);
  const loading = signal(true);
  const error   = signal(null);
  let controller;
  effect(() => {
    if (controller) controller.abort(); // cancel any in-flight request before starting a new one
    controller = new AbortController();
    loading.set(true);
    error.set(null);
    fetch(urlSignal.get(), { signal: controller.signal })
      .then(r => r.json())
      .then(json => { data.set(json); loading.set(false); })
      .catch(err => {
        if (err.name !== 'AbortError') { error.set(err.message); loading.set(false); } // AbortError is expected when we cancel; not a real failure
      });
  });
  return { data, loading, error };
}
import { signal, t } from 'kensington';
import { useFetch } from './use-fetch.js';
const userId = signal(1);
// derived signal: re-fetches automatically whenever userId changes
const { data, loading, error } = useFetch(userId.transform(id => `/api/users/${id}`));
document.body.append(
  t.div([
    t.div([
      t.button({ type: 'button', onclick: () => userId.set(v => v - 1) }, 'Prev'),
      t.span([' User ', userId, ' ']),
      t.button({ type: 'button', onclick: () => userId.set(v => v + 1) }, 'Next'),
    ]),
    // signal content can be a tag. Switches between loading, error, and data views reactively
    loading.transform(l => {
      if (l) { return t.p('Loading...'); }
      const err = error.get();
      return err ? t.p({ class: 'error' }, err) : t.pre(JSON.stringify(data.get(), null, 2));
    }),
  ]).toElement()
);

Portal

React's createPortal renders a subtree into a DOM node outside the parent component. Kensington has no portal API because .toElement() returns a real DOM node. Mount it wherever you want. A two-line helper appends the node and returns a remover. Wrap the call in an effect to tie the mount/unmount lifecycle to a signal.

// portal.js
function portal(target, fn) {
  const node = fn().toElement();
  target.append(node);
  return () => node.remove();
}
import { signal, effect, t } from 'kensington';
import { portal } from './portal.js';
const modalRoot = document.createElement('div');
modalRoot.id = 'modal-root';
document.body.append(modalRoot);
const isOpen = signal(false);
let remove = null;
effect(() => {
  if (isOpen.get()) {
    remove = portal(modalRoot, () =>
      t.div({
        class: 'overlay',
        onclick: e => { if (e.target === e.currentTarget) { isOpen.set(false); } },
      }, [
        t.div({ class: 'modal' }, [
          t.h2('Confirm'),
          t.p('Rendered outside the main app tree.'),
          t.button({ type: 'button', onclick: () => isOpen.set(false) }, 'Close'),
        ]),
      ]),
    );
  } else {
    remove?.();
    remove = null;
  }
});
document.body.append(
  t.button({ type: 'button', onclick: () => isOpen.set(true) }, 'Open modal').toElement(),
);

Styled components

Kensington already takes a style object on every tag ( { style: { backgroundColor: 'red' } } ). What inline styles can't do is pseudo-selectors, media queries, and reuse across components. styled(tag, styles) fills the gap. It takes a tag closure and a style object (same camelCase keys as the built-in style attribute, plus nested keys for pseudo-selectors and at-rules), injects a class into a shared stylesheet, and returns a new tag closure.

// styled.js
let _id = 0;
let _style;
const sheet = () => (_style ??= document.head.appendChild(document.createElement('style')));
const kebab = s => s.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
function toCss(selector, styles) {
  const decls = [];
  let nested = '';
  for (const [k, v] of Object.entries(styles)) {
    if (v && typeof v === 'object') {
      nested += k.startsWith('@')
        ? `${k} { ${toCss(selector, v)} } `     // @media, @supports, ...
        : `${toCss(selector + k, v)} `;          // :hover, > .child, &.primary, ...
    } else if (v !== null && v !== undefined && v !== false) {
      decls.push(`${kebab(k)}:${v}`);
    }
  }
  return (decls.length ? `${selector} { ${decls.join(';')} } ` : '') + nested;
}
// A plain attrs object is anything that isn't a tag, signal, array, or null/primitive.
// Matches how Kensington's own tag closures disambiguate (attrs, content) from (content).
function isAttrs(x) {
  return x !== null
    && typeof x === 'object'
    && !Array.isArray(x)
    && !x._isKensingtonTag
    && !x._isKensingtonSignal;
}
export function styled(tag, styles) {
  const className = `k-${++_id}`;
  sheet().textContent += toCss(`.${className}`, styles);
  return (...args) => {
    const hasAttrs = args.length > 0 && isAttrs(args[0]);
    const attrs = hasAttrs ? args[0] : {};
    const rest = hasAttrs ? args.slice(1) : args;
    const merged = { ...attrs, class: [className, attrs.class].filter(Boolean) };
    return tag(merged, ...rest);
  };
}

The returned tag is a plain Kensington tag closure. It takes the same arguments any tag does, and any extra props the caller passes (attributes, event handlers, content) flow through unchanged.

import { signal, t } from 'kensington';
import { styled } from './styled.js';
const Card = styled(t.section, {
  background: 'white',
  borderRadius: '0.5rem',
  padding: '1rem',
  boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
});
const Button = styled(t.button, {
  background: 'hsl(220 10% 90%)',
  color: 'hsl(220 10% 20%)',
  border: 0,
  padding: '0.5rem 1rem',
  borderRadius: '4px',
  cursor: 'pointer',
  ':hover':         { background: 'hsl(220 10% 85%)' },
  ':focus-visible': { outline: '2px solid hsl(220 80% 70%)' },
});
// Caller passes ordinary props: attributes, on-handlers, signal content.
const count = signal(0);
Button({ type: 'button', onclick: () => count.set(n => n + 1) }, [
  count.transform(n => `Clicked ${n} times`),
]);

Extending a styled component

Passing a styled tag as the first argument to styled composes them. Both classes apply, and later-defined styles win by source order in the stylesheet. No new helper, no new API.

const PrimaryButton = styled(Button, {
  background: 'hsl(220 80% 50%)',
  color: 'white',
  ':hover': { background: 'hsl(220 80% 40%)' },
});
const DangerButton = styled(Button, {
  background: 'hsl(0 70% 50%)',
  color: 'white',
  ':hover': { background: 'hsl(0 70% 40%)' },
});
// Each carries Button's base styles AND its own overrides.
Card([
  t.h2('Confirm'),
  PrimaryButton({ type: 'button' }, 'Save'),
  DangerButton({ type: 'button' }, 'Delete'),
]);

Variant props

For per-call variants, declare modifier classes inside the styles object and let the caller pick one. Combine with signal-derived class arrays for reactive variants.

const Alert = styled(t.div, {
  padding: '0.75rem 1rem',
  borderRadius: '4px',
  borderLeft: '4px solid',
  '.info':    { background: 'hsl(220 80% 95%)', borderLeftColor: 'hsl(220 80% 50%)' },
  '.warn':    { background: 'hsl(40 90% 95%)',  borderLeftColor: 'hsl(40 90% 50%)'  },
  '.error':   { background: 'hsl(0 80% 95%)',   borderLeftColor: 'hsl(0 80% 50%)'   },
});
const level = signal('info');
Alert({ class: level }, level.transform(l => `Status: ${l}`));

Reactive class values are already first-class in Kensington. Flipping level.set('warn') swaps the modifier class on the live element. The static base styles live in the generated class once.

useId

Generates a unique, stable ID for pairing form labels with inputs. A module-level counter increments once per call. On the server it produces the same sequence on every request, so IDs in SSR output and client hydration match as long as components are called in the same order.

// use-id.js
let _id = 0;
function useId(prefix = 'k') {
  return `${prefix}-${++_id}`;
}
import { t } from 'kensington';
import { useId } from './use-id.js';
function labeledInput(label, type = 'text') {
  const id = useId();
  return t.div({ class: 'field' }, [
    t.label({ for: id }, label),
    t.input({ id, type }),
  ]);
}
document.body.append(
  t.form([
    labeledInput('Full name'),
    labeledInput('Email', 'email'),
    labeledInput('Password', 'password'),
    t.button({ type: 'submit' }, 'Sign up'),
  ]).toElement()
);

Integrations

htmx

Pass 'hx' to additionalNamespaces to allow hx-* attributes. Alpine.js uses 'x' .

import Kensington from 'kensington';
const t = new Kensington({ additionalNamespaces: ['hx'] });
// Live search: htmx swaps in the result fragment
t.div([
  t.input({
    type: 'search',
    name: 'q',
    placeholder: 'Search...',
    hx: {
      get: '/search',
      trigger: 'input changed delay:300ms',
      target: '#results',
    },
  }),
  t.ul({ id: 'results' }),
]);
// The partial route returns just the <li> items (htmx swaps them into the <ul>)
app.get('/search', async (req, res) => {
  const rows = await db.search(req.query.q);
  res.send(rows.map(r => t.li(r.name)).join('\n'));
});

Tailwind CSS

The class array is a natural fit for Tailwind. Falsy entries are dropped, so conditional classes don't need ternaries or string concatenation.

import { t } from 'kensington';
function button(label, { variant = 'primary', disabled = false } = {}) {
  return t.button({
    type: 'button',
    disabled,
    class: [
      'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium',
      'focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors',
      variant === 'primary'   && 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
      variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
      variant === 'danger'    && 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
      disabled && 'opacity-50 cursor-not-allowed',
    ],
  }, label);
}
function card(title, body) {
  return t.div({ class: 'rounded-lg border border-gray-200 bg-white shadow-sm p-6' }, [
    t.h3({ class: 'text-lg font-semibold text-gray-900 mb-2' }, title),
    t.div({ class: 'text-gray-600 text-sm' }, body),
  ]);
}
function alert(message, type = 'info') {
  const styles = {
    info:    'bg-blue-50 text-blue-800 border-blue-200',
    success: 'bg-green-50 text-green-800 border-green-200',
    warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
    error:   'bg-red-50 text-red-800 border-red-200',
  };
  return t.div({ class: `rounded-md border px-4 py-3 text-sm ${styles[type]}` }, message);
}
t.div({ class: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8' }, [
  t.h1({ class: 'text-3xl font-bold text-gray-900 mb-6' }, 'Dashboard'),
  alert('Your trial expires in 3 days.', 'warning'),
  t.div({ class: 'mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3' }, [
    card('Users', '1,284 total'),
    card('Revenue', '$24,500 this month'),
    card('Active sessions', '42 right now'),
  ]),
  t.div({ class: 'mt-8 flex gap-3' }, [
    button('Save changes'),
    button('Cancel', { variant: 'secondary' }),
    button('Delete account', { variant: 'danger' }),
  ]),
]);

Alpine.js

Pass 'x' to additionalNamespaces to allow x-* attributes. The camelCase conversion handles xOn , xBind , xShow , etc. automatically.

import Kensington from 'kensington';
const t = new Kensington({ additionalNamespaces: ['x'] });
// Dropdown menu
function dropdown(label, items) {
  return t.div({ xData: '{ open: false }', class: 'dropdown' }, [
    t.button({
      type: 'button',
      x: {
        on: { click: 'open = !open' },
        bind: { ariaExpanded: 'open' },
      },
    }, label),
    t.ul({
      x: {
        show: 'open',
        on: { 'click.outside': 'open = false' },
      },
      class: 'dropdown-menu',
    }, items.map(item =>
      t.li(t.a({ href: item.href }, item.label))
    )),
  ]);
}
// Tabs
function tabs(items) {
  return t.div({ xData: '{ active: 0 }', class: 'tabs' }, [
    t.div({ class: 'tab-list', role: 'tablist' },
      items.map((item, i) =>
        t.button({
          type: 'button',
          role: 'tab',
          x: {
            on: { click: `active = ${i}` },
            bind: { class: `active === ${i} ? 'tab--active' : ''` },
          },
        }, item.label)
      )
    ),
    t.div({ class: 'tab-panels' },
      items.map((item, i) =>
        t.div({ role: 'tabpanel', xShow: `active === ${i}` }, item.content)
      )
    ),
  ]);
}

Elysia

Elysia runs on Bun. Pass the tag's string representation to new Response() and set the content-type header manually, since Elysia doesn't have a dedicated HTML response method.

import { Elysia } from 'elysia';
import { t } from 'kensington';
import { layout } from './layout.js';
const app = new Elysia()
  .get('/', () => new Response(
    layout('Home', t.h1('Welcome')),
    { headers: { 'content-type': 'text/html; charset=utf-8' } }
  ))
  .get('/users', async () => {
    const users = await db.getUsers();
    return new Response(
      layout('Users', [
        t.h1('Users'),
        t.ul(users.map(u => t.li(u.name))),
      ]),
      { headers: { 'content-type': 'text/html; charset=utf-8' } }
    );
  })
  .listen(3000);

Hono

Hono runs on Node, Bun, Deno, and Cloudflare Workers. Use c.html() to send a Kensington string as an HTML response.

import { Hono } from 'hono';
import { t } from 'kensington';
import { layout } from './layout.js';
const app = new Hono();
app.get('/', c => c.html(
  layout('Home', t.h1('Welcome'))
));
app.get('/users/:id', async c => {
  const user = await db.getUser(c.req.param('id'));
  return c.html(
    layout(user.name, [
      t.h1(user.name),
      t.p(user.bio),
    ])
  );
});
export default app;

For Cloudflare Workers, export app as the default and set compatibility_date in wrangler.toml . The same Kensington code runs unchanged across every Hono runtime.

Web Components

Kensington and signals map naturally onto the custom element lifecycle. Build the element tree with toElement() in connectedCallback and let the signal effects keep it up to date. Use persist: true on toElement() so effects pause on removal and resume on re-insertion rather than being destroyed.

import { t, signal, computed } from 'kensington';
class UserCard extends HTMLElement {
  #name = signal('');
  #role = signal('');
  #initials = computed(() => {
    return this.#name.get()
      .split(' ')
      .map(w => w[0])
      .join('')
      .toUpperCase();
  });
  static get observedAttributes() { return ['name', 'role']; }
  attributeChangedCallback(attr, _prev, next) {
    if (attr === 'name') this.#name.set(next ?? '');
    if (attr === 'role') this.#role.set(next ?? '');
  }
  connectedCallback() {
    this.replaceChildren(
      t.div({ class: 'user-card' }, [
        t.div({ class: 'user-card__avatar' }, this.#initials),
        t.div({ class: 'user-card__body' }, [
          t.strong(this.#name),
          t.span({ class: 'user-card__role' }, this.#role),
        ]),
      ]).toElement()
    );
  }
}
customElements.define('user-card', UserCard);

Passing a signal directly to a tag ( t.strong(this.#name) ) sets up a live text effect inside toElement() . Updating the attribute calls attributeChangedCallback , which sets the signal, which updates only the affected text node. The effects are cleaned up automatically when the element is removed from the DOM.

D3

Use Kensington to build the SVG container, then hand it to D3 for data-driven rendering. Wrap the D3 draw logic in an effect so the chart redraws automatically whenever the signal holding the data changes.

import { t, signal, effect } from 'kensington';
import * as d3 from 'd3';
const data = signal([12, 40, 28, 55, 33, 20, 47]);
const W = 500, H = 220;
const m = { top: 10, right: 10, bottom: 30, left: 34 };
const svg = t.svg({ width: W, height: H, viewBox: `0 0 ${W} ${H}` }).toElement();
document.getElementById('chart').replaceChildren(svg);
effect(() => {
  const values = data.get();
  const x = d3.scaleBand()
    .domain(values.map((_, i) => i))
    .range([m.left, W - m.right])
    .padding(0.2);
  const y = d3.scaleLinear()
    .domain([0, d3.max(values)])
    .nice()
    .range([H - m.bottom, m.top]);
  const chart = d3.select(svg);
  chart.selectAll('*').remove();
  chart.append('g')
    .attr('transform', `translate(0,${H - m.bottom})`)
    .call(d3.axisBottom(x).tickFormat(i => `Day ${i + 1}`));
  chart.append('g')
    .attr('transform', `translate(${m.left},0)`)
    .call(d3.axisLeft(y).ticks(5));
  chart.selectAll('rect')
    .data(values)
    .join('rect')
    .attr('x',      (_, i) => x(i))
    .attr('y',      d => y(d))
    .attr('width',  x.bandwidth())
    .attr('height', d => y(0) - y(d))
    .attr('fill',   'steelblue');
});
// Replace the data to redraw the chart.
document.getElementById('refresh').addEventListener('click', () => {
  data.set(Array.from({ length: 7 }, () => Math.round(Math.random() * 60) + 5));
});

D3 owns the contents of the SVG element. Kensington owns everything outside it. The surrounding layout, controls, and any other reactive UI on the page belong to Kensington. The two libraries operate in separate parts of the DOM and do not conflict.

Build systems

The recommended setup runs the full Kensington build in development (with runtime validation on) and the slim build in production (with no validation). The Vite example on the home page shows the pattern. The same idea works in every bundler that supports module aliasing.

Rollup

Use @rollup/plugin-alias to swap the import in production. @rollup/plugin-replace sets process.env.NODE_ENV so application code can pick a validationLevel at build time.

// rollup.config.js
import alias from '@rollup/plugin-alias';
import nodeResolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
const production = process.env.NODE_ENV === 'production';
export default {
  input: 'src/main.js',
  output: { file: 'dist/bundle.js', format: 'es' },
  plugins: [
    nodeResolve(),
    replace({
      preventAssignment: true,
      'process.env.NODE_ENV': JSON.stringify(production ? 'production' : 'development'),
    }),
    production && alias({
      entries: [{ find: 'kensington', replacement: 'kensington/dist/slim' }],
    }),
  ].filter(Boolean),
};
// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: process.env.NODE_ENV === 'production' ? 'off' : 'error',
});

Run with NODE_ENV=production rollup -c for the slim bundle, rollup -c for the full one.

esbuild

esbuild has built-in support for both aliasing and environment-variable replacement via alias and define . No plugins required.

// build.js
import esbuild from 'esbuild';
const production = process.env.NODE_ENV === 'production';
const alias = production ? { kensington: 'kensington/dist/slim' } : {};
await esbuild.build({
  entryPoints: ['src/main.js'],
  outfile: 'dist/bundle.js',
  bundle: true,
  format: 'esm',
  define: {
    'process.env.NODE_ENV': JSON.stringify(production ? 'production' : 'development'),
  },
  alias,
});
// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: process.env.NODE_ENV === 'production' ? 'off' : 'error',
});

Run node build.js for the dev build, NODE_ENV=production node build.js for the slim one.

Webpack

Webpack's mode option auto-sets process.env.NODE_ENV , and resolve.alias handles the import swap. A config function receives the mode so the alias map can be built per environment.

// webpack.config.js
const path = require('path');
module.exports = (env, argv) => {
  const alias = argv.mode === 'production' ? { kensington: 'kensington/dist/slim' } : {};
  return {
    entry: './src/main.js',
    output: {
      path: path.resolve(__dirname, 'dist'),
      filename: 'bundle.js',
    },
    resolve: { alias },
  };
};
// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
  validationLevel: process.env.NODE_ENV === 'production' ? 'off' : 'error',
});

Run webpack --mode development for the full build, webpack --mode production for the slim one.

GitHub

Kensington API

Method signatures, types, and exports. See the guide for usage examples.

Constructor

new Kensington(options?: {
  validationLevel?: 'off' | 'warn' | 'error';
  additionalNamespaces?: string | string[];
  additionalGlobalAttributes?: Record<string, unknown>;
  indentationLevel?: number;
  logger?: (message: string) => void;
})
Option Default Description
validationLevel 'off' Attribute validation behavior. 'off' disables validation entirely (required for the slim build). 'warn' logs via logger . 'error' throws.
additionalNamespaces . Allow extra attribute prefixes on all elements, e.g. 'hx' for htmx hx-* attributes or 'x' for Alpine.js.
additionalGlobalAttributes . Allow specific extra attributes on all elements. Same validator format as createCustomTag .
indentationLevel 2 Spaces per indentation level in .toString() output. Set to 0 to disable indentation.
logger console.log Called with warning messages when validationLevel is 'warn' .

Tag methods

Every HTML, SVG, and MathML element is available as a method on the Kensington instance. Attribute types are generated from the official specs. Each element's attribute type is named {PascalTag}Attributes (e.g. InputAttributes , AAttributes ).

Content elements

Most elements: div , p , span , section , a , table , and so on.

t.div(attributes: DivAttributes, content?: Content): DivTag
t.div(content?: Content): DivTag

A subset of elements have branded return types and enforce a strict content model in TypeScript. Passing the wrong child type is a compile-time error. All strict containers also accept literal() , inlineComment() , null , undefined , and boolean as escape hatches for conditional patterns.

Element(s) Return type Accepts
html HtmlTag head , body
table TableTag caption , colgroup , thead , tbody , tfoot , tr
thead , tbody , tfoot TheadTag , TbodyTag , TfootTag tr
tr TrTag td , th
colgroup ColgroupTag col , template
ul , ol , menu UlTag , OlTag , MenuTag li
dl DlTag dt , dd , div
select SelectTag option , optgroup , hr , div , button
optgroup OptgroupTag option , div , noscript , legend
picture PictureTag source , img
hgroup HgroupTag h1h6 , p

These elements also have branded return types but accept any Content : td , th , li , dt , dd , caption , option , body , head , and others. Void elements with branded types: img ( ImgTag ), col ( ColTag ), source ( SourceTag ), hr ( HrTag ).

Void elements

Void elements take no content argument: br , hr , input , img , link , meta , source , wbr , and others.

t.input(attributes?: InputAttributes): VoidTag
t.img(attributes?: ImgAttributes): ImgTag

Instance methods

All tag objects share these methods.

Method Returns Description
.toString() string Serializes to an indented HTML string. Text content is HTML-encoded. Signal values are read as a snapshot at call time.
.toElement(options?) Element Creates a live DOM node. Signal attribute values, signal content, and prop values update the DOM in place when the signal changes. Signal effects are stopped automatically when the element is removed from the DOM. Pass { persist: true } to pause effects on removal and resume them automatically on re-insertion instead of stopping permanently. Browser only. LiteralTag returns a DocumentFragment ; CommentTag returns a Comment .
.getDomElement() Element | null Returns the live DOM element created by a prior .toElement() call if it is still connected to the document, otherwise null .
.addConnectedCallback(fn) this Registers a callback that fires when the element is inserted into the DOM. fn receives the live element as its first argument and as this . Call before .toElement() . With toElement({ persist: true }) the callback re-fires on every re-insertion. Can be called multiple times to register multiple handlers.
.addDisconnectedCallback(fn) this Registers a callback that fires when the element leaves the DOM, after signal effects are stopped. With toElement({ persist: true }) the callback re-fires on every removal and is re-registered automatically on each reconnection, so the full enter/leave cycle repeats without extra setup.

Special methods

htmlWithDocType

Identical to t.html() but prepends <!DOCTYPE html> to the output.

t.htmlWithDocType(attributes: HtmlAttributes, content?: HtmlContent): ContentTag
t.htmlWithDocType(content?: HtmlContent): ContentTag

literal / unsafeLiteral

t.literal(str: string): LiteralTag
t.unsafeLiteral(str: string): LiteralTag

literal embeds a raw HTML string into the output. <script> tags trigger a validation warning or error. unsafeLiteral skips that check and should only be used for trusted HTML.

inlineComment

t.inlineComment(str: string | number): CommentTag

Single-line strings produce <!-- text --> . Multi-line strings are formatted across multiple lines.

createCustomTag

t.createCustomTag(
  tagName: string,
  allowedAttributes?: Record<string, AttributeValidator>
): ContentMethod<T>

Returns a method for a custom element. Assign to a class property and annotate with ContentMethod<T> for typed attributes.

Each value in allowedAttributes is a validator:

Validator Accepts
String Any string value
Number Any number value
Boolean true or false
['a', 'b', ...] One of the listed string literals
v => boolean Custom predicate function
class MyEngine extends Kensington {
  myCard = this.createCustomTag('my-card', {
    cardType: ['primary', 'secondary'],
    loading: Boolean,
    maxItems: Number,
    score: v => typeof v === 'number' && v <= 100,
  });
}

To extend a built-in element, spread its attribute object from kensington/attributes :

import { buttonAttributes } from 'kensington/attributes';
class MyEngine extends Kensington {
  button = this.createCustomTag('button', {
    ...buttonAttributes,
    popovertarget: String,
  });
}

Signals

Reactive values. Read with .get() , write with .set() , derive with computed() or .transform() . See the reactive data guide for usage.

signal

import { signal } from 'kensington';
signal<T>(initialValue: T): Signal<T>
signal<T>(initialValue: T, key: SignalKey): Signal<T>  // keyed form

Creates a writable signal holding initialValue . The keyed form is documented under Keyed forms .

Signal methods

Method Description
.get(): T Returns the current value. Inside computed() or effect() , registers this signal as a dependency.
.value: T Property getter. Returns the current value without tracking. Reading it inside computed() or effect() does not subscribe.
.set(value: T | (prev: T) => T): void Updates the value and notifies subscribers. Accepts a value or an updater function. Throws on signals from computed() or .transform() .
.transform<U>(fn, key?): Signal<U> Returns a read-only derived signal, equivalent to computed(() => fn(this.get()), key) . Tracks all signals read inside fn .
.stop(): void Clears all subscribers. For derived signals, also tears down the computation and freezes the value.
.toJSON(): T Returns the raw value without tracking. Makes signals transparent to JSON.stringify .
.toString(): string Calls .get() and converts to string. Tracks in reactive contexts.

computed

import { computed } from 'kensington';
computed<T>(fn: () => T): Signal<T>
computed<T>(fn: () => T, key: SignalKey): Signal<T>  // keyed form

Creates a read-only signal derived from other signals. Re-evaluates fn synchronously whenever any signal read inside it changes. Exposes .stop() to unsubscribe and freeze the value. The keyed form is documented under Keyed forms .

Keyed forms inside a computed

Pass a stable key as the second argument to scope the instance to the surrounding computed callback. The same key returns the same instance across outer re-runs. The instance is stopped automatically when its key leaves the list, and the whole registry is torn down when the owning computed is stopped.

Form Identity across outer re-runs
signal(initial, key) Same key returns the same signal. Only the first call's initial is used.
computed(fn, key) Same key returns the same inner instance. The fn closure is refreshed each run so captured values stay current.
signal.transform(fn, key) Same lifecycle as computed(fn, key) . Single-source.
type SignalKey = string | number | object | symbol;

Without a key these forms still work, but the instance is re-created on every outer re-run, local state resets, and a warning is logged. A keyed instance reference must not escape its owner. The no-out-of-scope-reactive-reference ESLint rule and a runtime warning catch escapes. See per-item local state in the guide.

signal.mapWithKey

signal.mapWithKey<Item, U>(
  keyOrProp: ((item: Item) => SignalKey) | keyof Item,
  mapFn: (item: Item) => U,
): ReadonlySignal<U[]>

Keyed list mapper. The first argument extracts the key (a function or a property-name string). mapFn runs once per key the first time the key is seen and the resulting tag is cached and reused on later renders. Call it at the same scope as signal() , not inside a computed or effect callback. See keyed lists in the guide.

effect

import { effect } from 'kensington';
effect(fn: () => void): { pause(): void, resume(): void, stop(): void }

Runs fn immediately and re-runs it whenever any signal read inside it changes. Synchronous .set() calls in the same turn are batched into one re-run. During SSR ( renderForHydration ) it is a no-op.

Method Description
.pause() Unsubscribes temporarily.
.resume() Re-runs fn and re-establishes subscriptions.
.stop() Permanently destroys the effect. resume() after stop() is a no-op.

prop key

Assigns DOM properties directly ( el[name] = value ) instead of setAttribute . Accepts a plain object whose values are static or ReadonlySignal . Ignored in .toString() . Property existence and writability are validated at render time and reported via validationLevel .

t.input({ prop: { value: 'hello' } });           // typed: HTMLInputElement.value
t.input({ prop: { checked: isChecked } });       // typed: boolean
t.video({ prop: { muted: true, playbackRate: 1.5 } });  // typed: HTMLVideoElement props
t.div({ prop: { _instance: component } });       // expando: accepted as unknown

renderForHydration

import { renderForHydration } from 'kensington';
renderForHydration<S, C = unknown>(
  fn: (state: S, context: C) => ContentTag | ContentTag[] | null | undefined,
  state: S,
  name?: string,
  options?: { context?: C },
): LiteralTag

Renders a synchronous component to an HTML string and embeds state as a <script type="application/json"> block for client hydration.

Argument Description
fn The component function. Receives state and context as positional arguments. Signal effects are suppressed during the call.
state A plain serializable object. Values that cannot survive JSON.stringify warn or throw.
name Defaults to fn.name server-side. Pass an explicit string in the browser and for anonymous functions. Must match the key used by registerComponents .
options.context Non-serializable runtime bag passed to fn as its second argument. Use it for transport handles, local signals, identity, or anything else that cannot round-trip through JSON. Never embedded in the SSR script block. The client supplies its own context via registerComponents .

registerComponents

import { registerComponents } from 'kensington';
registerComponents<C = unknown>(
  components: Record<string, (state: any, context: C) => ContentTag | ContentTag[] | null>,
  options?: { context?: C },
): { stop(): void }

Hydrates all server-rendered instances in the page, replacing each matching <script type="application/json" data-k-component="…"> block with live reactive DOM, and watches for components inserted later. Returns { stop() } to halt auto-hydration. Pass options.context to thread a non-serializable runtime bag into every registered component as its second argument. The framework forwards the same context to HMR hot-swaps.

// shared/app-page.js
export function appPage(state, env) {
  return t.main([header(env), seatGrid(env)]);
}
// server.js
const env = makeServerEnv();
renderForHydration(appPage, {}, 'appPage', { context: env });
// client.js
const transport = connectLive({ url: '/...' });
const env = makeClientEnv({ transport });
registerComponents({ appPage }, { context: env });

Live signals

The kensington/live subpath ships a server-synchronized signal primitive. With no transport registered, liveSignal returns a regular signal() so shared components stay unit-testable. See the live signals guide for setup, naming, and usage patterns.

liveSignal

import { liveSignal } from 'kensington/live';
liveSignal<T>(initial: T, name: string, options?: LiveSignalOptions): Signal<T>
interface LiveSignalOptions {
  persist?: boolean;                    // default false
  canWrite?: CanWrite;                  // default 'any'
}
type CanWrite =
  | 'any'
  | 'server-only'
  | ((name: string, ctx: any, transition: { prev, next }) => boolean);

Returns a Signal<T> shared by name across connected clients. Subsequent calls with the same name in the same process return the same instance. Values must round-trip through JSON.stringify (no circular references, BigInts, Maps, Sets, Dates, class instances, functions, or Symbols). Unserializable writes are rejected with a once-per-name warning so local state stays in sync with the broadcast.

persist: false (default) is in-memory only and dropped 30 seconds after the last subscriber leaves. persist: true writes through to the configured backend (memory or sqlite) and keeps the entry until live.delete(name) . First declaration per name wins; mismatched flags warn.

canWrite defaults to 'any' (authenticated clients may write). 'server-only' rejects all client writes. A function predicate validates writes against identity and the proposed transition. Layered with the global canWrite on liveServer ; both must allow.

.set under multiple clients

// Both forms return Promise<void>.
sig.set(value: T): Promise<void>
sig.set(fn: (prev: T) => T): Promise<void>

Same call sites as a regular signal. The return shape and the rejection contract are different.

Prefer the updater form whenever the new value depends on the current value.

LiveSetRejected

type LiveSetReason =
  | 'forbidden'           // canWrite predicate rejected the write
  | 'conflict'            // CAS lamport mismatch (retried internally; surfaced via 'retries-exhausted')
  | 'unserializable'      // value can't round-trip JSON, or contains NaN / Infinity
  | 'disconnected'        // transport is 'disconnected' or the socket dropped mid-flight
  | 'retries-exhausted'   // CAS write retried MAX_CAS_RETRIES times without converging
  | 'unsubscribed'        // signal was .stop()'d while a CAS retry was in flight
  | 'aborted';            // transport was close()'d while the write was in flight
interface LiveSetRejected<T = unknown> extends Error {
  name: 'LiveSetRejected';
  signalName: string;
  reason: LiveSetReason;
  attemptedValue: T | undefined;
  authoritativeValue: T | undefined;
}

Narrow via err instanceof Error && err.name === 'LiveSetRejected' . attemptedValue is what the caller tried to write; authoritativeValue is the server's truth at the moment of rejection (already applied to sig.value before the rejection fires).

try {
  await seat.set(myTabId);
} catch (err) {
  if (err instanceof Error && err.name === 'LiveSetRejected') {
    const e = err as LiveSetRejected<string>;
    toast(`${e.signalName}: ${e.reason}. owned by ${e.authoritativeValue}`);
  }
}

Connection status

type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected'

Read off the handle returned by connectLive() or liveServer() . Server-side .status is always 'connected' . Both are reactive Signal<ConnectionStatus> values.

connectLive

import { connectLive } from 'kensington/live';
connectLive(opts?: ConnectLiveOptions): ClientTransport
interface ConnectLiveOptions {
  url?: string;                       // default '/__kensington/live'
  reconnect?: {
    initialDelay?: number;            // default 250
    maxDelay?: number;                // default 30_000
    maxRetries?: number;              // default Infinity
  };
  onStatus?: (status: ConnectionStatus) => void;
  onFrame?: (direction: 'out' | 'in', frame: unknown) => void;
}
interface ClientTransport {
  status: Signal<ConnectionStatus>;
  close(): void;                      // terminal
  disconnect(): void;                 // drop + stay disconnected
  reconnect(): void;                  // drop + immediately re-open
  pauseSend(): void;                  // buffer outgoing writes
  resumeSend(): void;                 // flush buffer in FIFO
  unsubscribe(name: string): void;
}

Opens a WebSocket and registers the transport. Call once at app boot, before any liveSignal call. Reconnect is automatic with exponential backoff. See the live signals guide for setup.

Options

Option Description
url WebSocket URL. Defaults to '/__kensington/live' , matching liveServer 's default path. Override both together if you need a different path.
reconnect Reconnect policy. initialDelay (ms, default 250) and maxDelay (ms, default 30 000) set the exponential-backoff window. maxRetries (default Infinity ) caps attempts; once exhausted, status transitions to 'disconnected' and reconnect() resets the counter.
onStatus(status) Called each time the connection status changes. Same values as the reactive transport.status signal; use the callback form when you need the change outside a reactive context.
onFrame(direction, frame) Fires per WebSocket frame. direction is 'out' or 'in' . The type field on frame is the stable surface for inspection. Useful for protocol-level debugging.

Transport handle

Method / property Description
status Reactive Signal<ConnectionStatus> . Read to drive connection-state UI.
close() Terminal. Stops reconnect attempts and closes the WebSocket. No way back without a fresh connectLive() .
disconnect() Drop the WebSocket and stay disconnected. Call reconnect() to come back.
reconnect() Drop and immediately re-open. Subscriptions survive. Backoff resets.
pauseSend() / resumeSend() Buffer outgoing writes locally; reads still apply. Status stays at 'connected' .
unsubscribe(name) Stop following one name. The local Signal stays valid; updates stop arriving.

liveServer

import { liveServer } from 'kensington/live';
liveServer<Ctx = unknown>(opts?: LiveServerOptions<Ctx>): Promise<LiveServer>
interface LiveServerOptions<Ctx> {
  persistence?: PersistenceConfig;    // default { kind: 'memory' }
  canRead?:  (name: string, ctx: Ctx) => boolean;
  canWrite?: CanWrite<Ctx>;           // default 'any'
  onConnect?: (ws: unknown, req: IncomingMessage) => Ctx | Promise<Ctx>;
  onSocketClose?: (ctx: Ctx, ws: unknown) => void;
  path?: string;                      // default '/__kensington/live'
  heartbeatInterval?: number | false; // default 30_000 (ms). false disables.
}
type PersistenceConfig =
  | { kind: 'memory' }
  | { kind: 'sqlite'; path: string; flushInterval?: number };
interface LiveServer<Ctx = unknown> {
  status: Signal<ConnectionStatus>;
  readonly heartbeatInterval: number | false;
  get<T = unknown>(name: string): T | undefined;
  set(name: string, value: unknown, options?: { persist?: boolean }): void;
  list(prefix: string): Array<[string, unknown]>;
  policyOf(name: string): boolean | undefined;
  contextFor(ws: unknown): Ctx | undefined;
  delete(name: string): void;
  close(): void;
  attach(httpServer: HTTPServer): Promise<AttachedWebSocketServer>;
  bunWebsocket(): BunWebSocketHandlers;
}

Creates the server-side runtime. await it at startup. Server-side .set calls bypass canWrite ; it gates client writes only.

Options

Option Description
persistence { kind: 'memory' } (default) keeps state in-process; lost on restart. { kind: 'sqlite', path: './data/live.db' } persists names declared with persist: true across restarts. Optional flushInterval (ms, default 250) debounces writes so bursts coalesce into single transactions.
canRead(name, ctx) Per-name read gate called on subscribe. Return false to reject; the client receives MSG_ERROR . ctx is the object returned by onConnect .
canWrite Global write policy applied before any per-signal canWrite check. 'any' (default) allows all client writes. 'server-only' blocks all client writes. A function (name, ctx, transition) => boolean gates per-name. Rejected writes return MSG_SET_FAIL to the originating client.
onConnect(ws, req) Called once per WebSocket open. Return a context object (sync or async) threaded into canRead , canWrite , onSocketClose , and contextFor() . Use it to read cookies, validate tokens, and attach a user id.
onSocketClose(ctx, ws) Called once per WebSocket close with the ctx from onConnect . Release per-user state here (presence slots, locks, in-flight writes) without waiting for transient-drop TTLs.
path WebSocket mount path. Defaults to '/__kensington/live' . Override both this and connectLive({ url }) together if you need a different path.
heartbeatInterval Milliseconds between WebSocket pings on the attach() path (default 30_000 ). Sockets that miss a pong are terminated, firing onSocketClose so silent drops release locks and presence in ~one interval. false disables. No effect on bunWebsocket() .

Handle

Method / property Description
attach(server) Mounts the WebSocket handler on a Node HTTP server (requires the ws peer dep). Returns the underlying WebSocketServer .
bunWebsocket() Returns the handler config to spread into Bun's websocket slot.
close() Terminates open WebSocket clients, then closes the WSS, then the persistence store. Call from SIGINT before httpServer.close() .
get(name) , set(name, value) , list(prefix) , delete(name) Read, write, enumerate, and remove registry entries from server-side code. Server set bypasses canWrite . delete is registry cleanup only — does NOT notify subscribers. Use set(name, null) when subscribers should observe the removal.
policyOf(name) Resolved persist policy: true / false / undefined . Pair with list() to classify entries in diagnostic UIs.
contextFor(ws) Returns the ctx object that onConnect returned for a given socket, or undefined if the socket is not tracked. Use to correlate wss.clients entries with per-socket identity without casting to any . Typed as Ctx | undefined when liveServer<Ctx> is called with an explicit type parameter.
heartbeatInterval (read-only) The configured heartbeat cadence in milliseconds, or false . Use for SSR state threading so clients can render "last beat N ago" relative to a known interval.

Outside renderForHydration , server-side liveSignal(initial, name) returns a long-lived Signal that subscribes to registry updates. Wrap an effect() around it to react to client writes and server-side mutations.

Import paths

kensington/live . Recommended. Exports liveSignal , connectLive , and liveServer . Works in Node, Bun, tests, bundlers, and no-bundler importmap deployments; node-only dependencies are dynamic.

kensington/live/client ( liveSignal + connectLive ) and kensington/live/server ( liveServer ) exist for users who want environment boundaries enforced at the import level.

Exports

kensington

import Kensington from 'kensington';                         // the class
import { t } from 'kensington';                              // shared default instance (new Kensington())
import { signal, computed, effect } from 'kensington';
import { renderForHydration, registerComponents } from 'kensington';
import { isBrowser } from 'kensington';                      // true when window is defined
// browser, via CDN
import { t } from 'https://cdn.jsdelivr.net/npm/kensington/dist/kensington.min.js';

kensington/attributes

Every element has a named export containing its allowed-attribute validator object. Useful for extending built-in elements via createCustomTag .

import {
  divAttributes,
  inputAttributes,
  formAttributes,
  buttonAttributes,
  aAttributes,
  // ... one export per element
} from 'kensington/attributes';

kensington/live

Multi-client state shared across connected browsers. See the Live signals API and the live signals guide .

import { liveSignal, connectLive, liveServer } from 'kensington/live';

Two narrower subpaths exist for environment-bounded imports. kensington/live/client exports liveSignal and connectLive . kensington/live/server exports liveServer . The unified kensington/live path is the documented default.

kensington/devtools

Side-effect import that enables the in-page devtools panel for inspecting signals, effects, and DOM bindings. Drop into a dev-only entry point. The panel mounts itself on first import.

// dev-only entry. Importing for side effects.
import 'kensington/devtools';

kensington/vite

Vite plugin that wires transparent HMR for component files. Accepts an include option (glob string, array of globs, or a callback returning either). On save, registered components hot-swap in place. Apply mode is 'serve' only. Production builds ship the user's original source with no instrumentation. Requires acorn and magic-string as optional peer deps (lazy-loaded when the plugin runs).

// vite.config.js
import { defineConfig } from 'vite';
import { kensingtonHmr } from 'kensington/vite';
export default defineConfig({
  plugins: [kensingtonHmr({ include: 'src/components/**/*.js' })],
});

Slim build

Proxy-based class with no per-element attribute spec data. About 5× smaller minified (~148 KB to ~27 KB). For signal-only consumers tree-shaking drops the bundle to ~1.5 KB. Throws if validationLevel is anything other than 'off' . See Dev vs production for the recommended workflow.

import Kensington from 'kensington/dist/slim';
const t = new Kensington();

TypeScript types

import type {
  ContentTag, VoidTag, LiteralTag, CommentTag,
  Content, ContentMethod,
  Signal, ReadonlySignal, Reactive,
  GlobalAttributes, GlobalEvents, UniversalAttributes, NameSpaceAttributes,
  // branded element types:
  DivTag, TdTag, ThTag, TrTag, TheadTag, TbodyTag, TfootTag,
  TableTag, UlTag, OlTag, LiTag, DlTag, SelectTag, ImgTag,
  // ...
} from 'kensington';
Type Description
ContentTag Base type returned by all content element methods. All branded element types extend this.
VoidTag Returned by void element methods ( br , input , …). Extends ContentTag .
LiteralTag Returned by .literal() and .unsafeLiteral() .
CommentTag Returned by .inlineComment() .
DivTag , TdTag , LiTag , … Branded return types for elements with content model constraints. Extend ContentTag .
Content string | number | boolean | null | undefined | ContentTag | VoidTag | LiteralTag | CommentTag | Content[] . Falsy values are silently dropped.
ContentMethod<T> Type of a custom element method created by createCustomTag . T is the element-specific attribute shape.
Signal<T> Writable signal returned by signal() . Implements ReadonlySignal<T> .
ReadonlySignal<T> Read-only signal interface returned by computed() and .transform() . Exposes .get() , .value , .stop() , and .transform() .
Reactive<T> T | ReadonlySignal<T> . The type of every attribute value. Accepts a plain value or a signal that resolves to that value.
GlobalAttributes Attributes shared by all HTML elements ( id , class , style , …).
GlobalEvents Event handler attributes ( onclick , oninput , …) shared by all elements.
NameSpaceAttributes Interface to extend via module augmentation to allow custom attribute namespaces.
UniversalAttributes Intersection of GlobalAttributes , GlobalEvents , and NameSpaceAttributes .

Module augmentation

Extend NameSpaceAttributes to allow custom attribute prefixes without a custom subclass:

declare module 'kensington' {
  interface NameSpaceAttributes {
    [key: `hx${string}`]: string | object; // htmx hx-* attributes
  }
}

Want to understand how everything works under the hood? See the architecture guide .

GitHub

Architecture

A complete trace of what happens from t.div(...) through DOM teardown. Every signal subscription, every cleanup hook, every step of the pipeline.

Introduction

This document is the deep-dive companion to the source code. It traces what happens during the life of a Kensington tag instance, from the moment t.div(...) is called until the resulting DOM node and its signal subscriptions are torn down.

You don't need to read this to use Kensington. Read it if you're:

Throughout this page, source references appear as signal.js . Click to open the file on GitHub. Line numbers are approximate and may drift as the code evolves.

Concepts at a glance

If you've never read the source, these are the seven moving parts you'll see referenced throughout. Each links to the section that explains it in full.

Concept What it is Lives in
Tag instance The object returned by t.div(...) . Holds attributes, content, namespace, and lifecycle callback arrays. Two output methods: toString() and toElement() . content-tag.js
Signal A reactive value container. .get() subscribes the current effect; .set() schedules every subscriber. .value reads without subscribing. signal.js
effect A closure that re-runs whenever any signal it reads changes. Exposes pause , resume , stop . signal.js
Lifecycle Per-element orchestrator that owns every signal effect, the persist mechanism, and the connect/disconnect callback chain. lifecycle.js
DOM tracker A shared MutationObserver that fires stop chains on removal and connect callbacks on insertion. One per document. dom-tracker.js
Reconciler Reorders and inserts DOM when a signal value is an array. Matches nodes by an internal key set on the tag instance by signal.mapWithKey , so the user-visible DOM stays free of bookkeeping attributes. reconcile.js
persist mode Opt-in via the persist tag option. Effects pause on removal and resume on reconnect. Disconnect/connect callbacks fire every cycle. lifecycle.js
The mental model in three sentences

Every tag is a plain object that becomes a string or an element on demand. When it becomes an element, a Lifecycle wires every signal-driven value into an effect bound to that element via WeakRef . A document-wide MutationObserver watches for that element's removal and tears the effects down, or pauses them if persist is on.

Construction String output DOM output Lifecycle Removal

The Pipeline

Kensington has two output modes from one tag instance. The same ContentTag object can produce an HTML string via toString or a live DOM tree via toElement . The pipelines diverge only at the rendering stage.

flowchart TD
  A["t.div(attrs, content)"] --> B["createTag closure"]
  B --> C["new ContentTag(options)"]
  C --> D["collectContent: flatten arrays, drop falsy"]
  D --> E{"validationLevel != 'off'?"}
  E -- yes --> F["validate(tag)"]
  E -- no --> G["tag instance returned"]
  F --> G
  G --> H{"User calls..."}
  H -- "toString()" --> I["renderToString"]
  H -- "toElement()" --> J["DOM build + Lifecycle"]
  I --> K["HTML string"]
  J --> L["Live element"]

t is an instance of the generated Kensington class at kensington.js . Every tag method ( t.div , t.span , etc.) is a closure produced by createTag . The closure captures the tag name, the allowed-attribute spec map, the Klass (which subclass to instantiate), and per-tag options like namespace or contentIsLiteral .

Stage 1: Tag Construction

The closure returned by createTag accepts several call forms:

t.div();                              // no attributes, no content
t.div('hello');                        // content only
t.div({ class: 'a' });                 // attributes only
t.div({ class: 'a' }, 'hello');        // attributes + content
t.div({ class: 'a' }, [t.p(), t.p()]); // attributes + array content

The closure body disambiguates these forms by inspecting the first argument's prototype. A plain object ( Object.prototype or null prototype) is treated as attributes. Anything else, a tag instance, array, string, number, or Signal, is treated as content.

The createTag closure

At kensington.js , each closure instantiates the appropriate tag class with a consistent options object:

const instance = new Klass({
  additionalGlobalAttributes: this.additionalGlobalAttributes,
  allowedAttributeMap,    // built once when createTag was called
  attributes,
  content,
  contentIsLiteral,
  encodeContent,
  indentationLevel: this.indentationLevel,
  logger: this.logger,
  namespace,
  namespaces: this.namespaces,
  tagName,
  validationLevel: this.validationLevel,
});

The allowedAttributeMap is built once when createTag is first called and shared across every invocation of that closure. Validating t.div(...) a million times does not rebuild the spec map a million times.

The ContentTag constructor

esm / tag-classes / content-tag.js

The constructor stores options on instance fields, flattens content via collectContent, and initializes private callback arrays:

class ContentTag {
  #connectedCallbacks = [];
  #disconnectedCallbacks = [];
  #domElement = null;
  constructor(options) {
    this.tagName = options.tagName;
    this.attributes = options.attributes;
    this.prop = options.attributes?.prop ?? null;
    this.validationLevel = options.validationLevel;
    this.content = collectContent(options.content);
    // ... other fields
  }
}

collectContent

Defined at content-tag.js . Recursively flattens nested arrays into a single linear list and drops items that should not render:

function collectContent(items, seen = new Set()) {
  const out = [];
  for (const c of [].concat(items)) {
    if ([undefined, null, '', false, true].includes(c)) {
      continue; // false/true arise from conditional patterns: condition && t.span(...)
    }
    if (Array.isArray(c)) {
      if (seen.has(c)) { continue; } // cycle detection
      seen.add(c);
      out.push(...collectContent(c, seen));
      continue;
    }
    out.push(c);
  }
  return out;
}

Validation

If validationLevel is 'warn' or 'error', the tag runs validate() immediately after construction (see validate.js ):

  1. Collect unallowed attributes. Filter keys through attributeIsValid . Allowed if it's on or prop , in allowedAttributeMap , matches a namespace prefix ( data- , aria- , custom), or is in additionalGlobalAttributes .
  2. Report them via showInvalid. At 'warn' this logs; at 'error' this throws.
  3. Collect invalid attribute values. For each allowed attribute, run attributeValueIsValid against the type spec.
  4. Report invalid values as a single combined message so the developer sees all problems at once.

Signal instances are accepted unconditionally for any attribute type. The actual value is only inspected at render time. See validate.js .

Stage 2: String Output

tag.toString() delegates to renderToString at serialize.js :

  1. Filter invalid content via validateContent() . Items that aren't a string, finite number, tag instance, or Signal are dropped and reported via showInvalid.
  2. Open the tag. Concatenate '<' , the tag name, the attribute string, and '>' .
  3. Render the content body via one of three paths (below).
  4. Close the tag. Concatenate '</' , the tag name, '>' .

Three content paths

renderToString picks a path based on tag type and content shape:

Path A
Literal content
For <script> and <style> tags ( contentIsLiteral ). Content is joined by newlines without HTML encoding.
Path B
Short single-line
Fast path when content is a single string or number under 100 characters with no line breaks. Concatenates directly without the stringifyContentArray and indent overhead.
Path C
Multi-line indented
Everything else. Resolves Signals via .get() , flattens, passes to stringifyContentArray, then applies indent at the tag's indentation level.

The selector is contentIsShort(tag) at serialize.js :

export function contentIsShort(tag) {
  if (!tag.content.length) { return true; }
  if (tag.content.length > 1) { return false; }
  let [content] = tag.content;
  if (isKensingtonSignal(content)) { content = content.get(); }
  if (!['string', 'number'].includes(typeof content)) { return false; }
  if (content.length > 100) { return false; }
  return !LINE_BREAK_TEST_REGEX.test(content);
}

Attribute serialization

attributeString(tag) calls attributesStringFromObject at attributes.js . It iterates the attribute array and serializes each pair as name="value" , encoding the value through the he encoder wrapper. Booleans render as the bare attribute name ( disabled not disabled="true" ). Function values cannot be serialized to strings and are silently omitted, with the handleFunctionValues callback invoking showInvalid at that point rather than at tag creation.

Attribute composition

Before serialization, attributesArrayFromObject normalizes the options object into the flat [name, value] array that both toString() and toElement() consume.

  • camelCase keys convert to kebab-case, and nested namespace objects expand. { data: { bs: { toggle: 'collapse' } } } becomes data-bs-toggle="collapse" .
  • A style object is stringified to a declaration list. camelCase property names convert to kebab-case, and null , undefined , and false values are omitted.
  • A class array is joined to a single string. Falsy entries drop out.
  • The prop and on keys are skipped here. They never enter the HTML attribute pipeline.

Stage 3: DOM Output

tag.toElement(opts) is the heavy path. It builds a live DOM element, wires every signal-attribute, prop, and content into an effect, registers connect/disconnect callbacks with the DOM tracker, and returns the element ready to be inserted into the document.

The function lives at content-tag.js .

flowchart TD
  S(["toElement()"]) --> A{"domElement cached?"}
  A -- yes --> A1{"isConnected?"}
  A1 -- yes --> R1["showInvalid + return cached"]
  A1 -- no --> A2{"persist?"}
  A2 -- yes --> R2["return cached"]
  A2 -- no --> A3{"hasStaleDescendantBindings?"}
  A3 -- yes --> A4["clear cache, fall through"]
  A3 -- no --> A5{"parentNode != null?"}
  A5 -- yes --> R3["showInvalid + return cached"]
  A5 -- no --> R4["return cached"]
  A -- no --> B["validateContent"]
  A4 --> B
  B --> C["createElement(NS)"]
  C --> D["createLifecycle(element, persist)"]
  D --> E["For each attribute"]
  E --> E1{"Value type?"}
  E1 -- "on*+function" --> E2["addEventListener"]
  E1 -- "Signal" --> E3["lifecycle.signalEffect"]
  E1 -- "plain" --> E4["setAttribute"]
  E2 & E3 & E4 --> F["For each 'on' event"]
  F --> F1["addEventListener"]
  F1 --> G{"Has props?"}
  G -- yes --> G1["For each prop: Signal? signalEffect : assign"]
  G -- no --> H["For each content item"]
  G1 --> H
  H --> H1{"Item type?"}
  H1 -- "ContentTag/Literal/Comment" --> H2["recurse toElement, append"]
  H1 -- "Signal" --> H3["anchors + signalEffect -> reconcile"]
  H1 -- "plain" --> H4["createTextNode"]
  H2 & H3 & H4 --> I["lifecycle.finalize"]
  I --> J{"hasSignalContent?"}
  J -- yes --> K["markContentTracked"]
  J -- no --> L["cache domElement"]
  K --> L
  L --> R3["return element"]

Cache check

If #domElement is set, the cache check has four branches:

  • Already in the DOM ( isConnected ). Returning the cached element would silently move the node. showInvalid reports it, then the cached element is returned anyway.
  • Persist mode. Effects are paused, not stopped. Return the cached element unconditionally.
  • Disconnected and has stale descendant bindings. A descendant's effects were stopped on removal. Reusing would return a subtree with dead effects. Drop the cache and fall through to a fresh build.
  • Disconnected, no stale bindings. Covers never-mounted elements, static subtrees, elements pre-built before their parent mounts, non-reactive removed elements, and elements in a detached in-memory tree. Return the cached element. For the in-memory tree case ( parentNode !== null but !isConnected ), showInvalid also reports it since the node will be moved.
if (this.#domElement) {
  if (this.#domElement.isConnected) {
    showInvalid('toElement() called on a tag instance already in the DOM ...', ...);
    return this.#domElement;
  }
  if (persist) { return this.#domElement; }
  if (this.#hasStaleDescendantBindings()) {
    this.#domElement = null;
  } else {
    if (this.#domElement.parentNode !== null) {
      showInvalid('toElement() called on a tag instance already in the DOM ...', ...);
    }
    return this.#domElement;
  }
}

Element creation

const element = this.namespace ? document.createElementNS(this.namespace, this.tagName) : document.createElement(this.tagName);
const lifecycle = createLifecycle({ element, persist });
let hasSignalContent = false;

SVG and MathML tags carry their namespace through the createSvgContentTag and createMathTag factories. For HTML tags, namespace is undefined and createElement is used.

Attribute wiring

Iterates the result of attributeArray() , a flat list of [name, value] pairs after camelCase-to-kebab conversion, nested-namespace expansion, style-object stringification, and class-array joining. For each pair:

Match Action
onclick , oninput , etc. with a function value element.addEventListener(name.slice(2), fn)
Signal value lifecycle.signalEffect(sig, apply, attrName)
Plain value element.setAttribute(name, value)

The signal-attribute apply function:

lifecycle.signalEffect(attrValue, (el, val) => {
  if (val === false || val === null || val === undefined) {
    el.removeAttribute(attrName);
  } else if (val === true) {
    el.setAttribute(attrName, '');   // bare attribute (disabled, checked, etc.)
  } else {
    el.setAttribute(attrName, String(val));
  }
}, attrName);

The effect runs once immediately to set the initial value, then re-runs whenever the signal changes. Inside the effect, el is the result of elementRef.deref() inside the lifecycle module. If the element has been garbage-collected, the effect self-stops.

Event handlers (the on object)

The on attribute attaches multiple event handlers via a single nested object:

t.button({ on: { click: handleClick, mouseenter: handleHover } }, 'Press me')

The loop calls element.addEventListener(eventName, handler) for each function value. No cleanup is needed. Event listeners are released when the element is garbage-collected.

Prop wiring

The prop key sets DOM properties directly, not attributes. This matters for things like input.value (DOM property reflects current state) vs. input[value] (attribute reflects initial state only):

t.input({ prop: { value: count } })  // input.value updates as count changes
t.input({ value: count.get() })      // frozen attribute set at construction time

isPropWritable validates each property against the live element before assignment. If the property exists on the prototype but is read-only, showInvalid reports it and the assignment is skipped. Otherwise:

if (isKensingtonSignal(propValue)) {
  lifecycle.signalEffect(propValue, (el, val) => { el[propName] = val; }, 'prop:' + propName);
} else {
  element[propName] = propValue;
}

Content wiring

For each item in the flattened content array (see content-tag.js ):

Case 1
Tag instance
Recurse into child.toElement() and append. The child has its own lifecycle. The parent does not own its cleanup.
Case 2
Signal value
Insert two comment-node anchors. Wire a signal effect that calls reconcile on every change. Set hasSignalContent so markContentTracked runs after.
Case 3
Plain value
Create a text node and append it.

The signal-content wiring:

if (isKensingtonSignal(node)) {
  hasSignalContent = true;
  const startAnchor = document.createComment('');
  const endAnchor = document.createComment('');
  element.append(startAnchor, endAnchor);
  lifecycle.signalEffect(node, (el, val) => {
    reconcile(el, startAnchor, endAnchor, Array.isArray(val) ? val : [val]);
  }, '(content)');
  continue;
}

Signal as a standalone tag

A Signal can also be rendered without a wrapping element, by calling signal.toElement() or signal.mount(target) directly. The mechanism lives in signal-render.js .

// in esm/lib/reactive/signal-render.js
export function renderSignalAsTag(signal) {
  const startAnchor = document.createComment('');
  const endAnchor = document.createComment('');
  const frag = document.createDocumentFragment();
  frag.append(startAnchor, endAnchor);
  // ... WeakRef setup ...
  const eff = _internalEffect(() => {
    clearBetween(start, end);
    const value = signal.get();
    const items = Array.isArray(value) ? value : [value];
    for (const item of items) start.parentNode.insertBefore(renderItem(item), end);
  });
  trackForStop(startAnchor, () => eff.stop());
  return frag;
}

Deliberately simple. Unlike signal-as-content (a child of a real tag), this path does NOT route through reconcile . Each change clears all sibling nodes between the anchors and renders the new value fresh. Keyed-list matching, preserve-state restoration, and bidirectional diffing are unnecessary for a standalone signal whose value is typically a single tag (the "swap between two views" pattern). Keeping reconcile and preserve-state out of the slim build's hot path is the reason. If you need keyed reconciliation around a signal, wrap it in a tag:

t.div([signal.mapWithKey('id', renderRow)])

This is what makes signal.transform(v => v ? tagA : tagB) usable as a function return value. The returned ReadonlySignal<ContentTag> is itself a renderable thing. No wrapping t.div([signal]) is required just to satisfy the ContentTag return type at the call site.

Lifecycle finalize

After all wiring, the lifecycle is finalized:

lifecycle.finalize({
  connectCallbacks: this.#connectedCallbacks,
  disconnectCallbacks: this.#disconnectedCallbacks,
  onCleared: () => { if (this.#domElement === element) { this.#domElement = null; } },
  onReconnect: () => { this.#domElement = element; },
});
if (hasSignalContent) {
  markContentTracked(element);
}
this.#domElement = element;
return element;
  • connectCallbacks. User-registered via addConnectedCallback . Fire on every insertion when persist is true. Once otherwise.
  • disconnectCallbacks. User-registered via addDisconnectedCallback . Fire on every removal.
  • onCleared. Internal. Resets #domElement to null after removal so getDomElement() returns null.
  • onReconnect. Internal. Restores #domElement to the live element on re-insertion under persist mode.

Signal Anatomy

Before tracing the lifecycle module, here is how a Signal works. The full implementation is at signal.js .

Subscription via .get()

A Signal's subscribers are kept in a private Set on the instance. The mechanism that wires up a subscription is the module-scoped currentEffect reference, set during an effect() or computed() run:

get() {
  if (currentEffect !== null && !this.#subscribers.has(currentEffect)) {
    this.#subscribers.add(currentEffect);
    currentEffect._reads.add(this);
    currentEffect._cleanups.push(this);          // push the Signal, not a per-sub closure
  }
  return this.#value;
}
  • Calling .get() outside an effect or computed registers no subscription. It's just a read.
  • Calling .get() twice in the same effect is idempotent. The has(currentEffect) check prevents duplicates.
  • The Signal itself is pushed to the effect's _cleanups array. On re-run or stop, track() walks the array and calls sig._unsubscribeFromRun(run) on each entry. Pushing the Signal instead of a per-subscription closure removes one closure allocation per signal read, which adds up to many thousands per render of a typical list.

Writes and the microtask flush

.set(next) at signal.js compares via Object.is and bails on equality. Otherwise it updates the value and notifies subscribers:

sequenceDiagram
  participant U as User code
  participant S as Signal
  participant Q as pending Set
  participant Mt as queueMicrotask
  participant E as effect.run
  U->>S: .set(next)
  S->>S: Object.is(next, current)?
  alt equal
    S-->>U: return early
  else changed
    S->>S: value updated
    loop each subscriber
      alt subscriber is effect
        S->>Q: scheduleRun(fn)
        S->>Mt: queueMicrotask(flush)
      else subscriber is computed.update
        S->>E: update() synchronously
      end
    end
    S-->>U: return
    Mt->>Q: flush()
    loop each pending fn
      Q->>E: run()
    end
  end

Effects are batched. Multiple .set() calls in the same synchronous turn coalesce into a single re-run per effect because pending is a Set .

Computed updates run synchronously. This is intentional. A computed reading a.get() + b.get() must always be consistent with the latest values of a and b .

effect()

effect(fn) at signal.js guards against misuse before delegating to an internal createEffect(fn) helper. If called inside a running effect or computed body, it fires a throttled error because a new effect is started on every re-run without stopping the old one.

export function effect(fn) {
  if (inComputedFn) {
    throttledError('effect-in-computed', 'kensington: effect() called inside a computed or transform callback...');
  } else if (currentEffect !== null) {
    throttledError('effect-in-effect', 'kensington: effect() called inside an effect callback...');
  }
  return createEffect(fn);
}

createEffect(fn) is the shared implementation:

function createEffect(fn, isInternal = false) {
  if (isSSRMode()) {
    return { pause() {}, resume() {}, stop() {} };
  }
  let paused = false;
  let destroyed = false;
  function run() {
    if (paused) { return; }
    track(run, fn);
  }
  run._cleanups = [];
  run._isEffect = true;
  run._isInternal = isInternal;
  run();
  return {
    pause() {
      paused = true;
      pending.delete(run);
      for (const cleanup of run._cleanups) { cleanup(); }
      run._cleanups = [];
    },
    resume() {
      if (destroyed) { return; }
      paused = false;
      run();
    },
    stop() {
      this.pause();
      destroyed = true;
    },
  };
}

The three returned methods give the caller control:

pause

Drains _cleanups (unsubscribing from every signal) and removes itself from pending . The effect won't re-run until resume() is called.

resume

Calls run() immediately, re-tracking subscriptions to every signal read inside it. No-op if destroyed is true.

stop() calls pause() and sets destroyed = true , making resume() a permanent no-op. This is the teardown path when an element is removed without persist mode.

_internalEffect(fn) is identical to effect(fn) but passes isInternal = true to createEffect , which sets run._isInternal = true on the effect's run function. That flag suppresses the out-of-scope-reactive-reference warning when the effect subscribes to a keyed primitive, because DOM-binding effects are tied to the DOM and not a genuine user-land capture. Devtools categorises these effects as DOM bindings via markNextEffectAsBinding . Callers include lifecycle.signalEffect (via _bindingEffect ), LiteralTag , CommentTag , and map-with-key.js (for the keep-alive subscriber that holds the per-key inner computed awake).

_bindingEffect

_bindingEffect(sig, fn) is a single-signal fast-path effect used by every lifecycle attribute, content, prop, and style binding. It bypasses track() entirely. No _cleanups iteration, no _reads Set , no currentEffect dance. Subscribe and unsubscribe go through sig._bindingSubscribe(run) and sig._bindingUnsubscribe(run) . The single signal it observes drives one DOM property, so collapsing the round-trip removes ~20k subscribe / resubscribe pairs in benchmarks that fire many updates.

_internalComputed

_internalComputed(fn) is the library-internal pair of _internalEffect . It clears inComputedFn and currentEffect around the computed(fn) call so the computed-in-computed entry warning (intended for user mistakes) does not fire when the library is intentionally creating an inner computed. Used by map-with-key.js for the per-key reactive path.

computed()

computed(fn) at signal.js creates a Signal whose value is derived from other signals. Updates are synchronous (unlike effects).

Under isSSRMode() (the counter lives in ssr.js ), fn() runs once with no currentEffect set, so source .get() calls do not register a subscription. The returned Signal carries the snapshot value and never updates. This prevents per-request computed calls from leaking subscribers onto module-level signals that outlive the request.

Auto-dispose: when a computed's last subscriber is removed, a sleep callback unsubscribes from all sources and freezes the value. On the next .get() inside a reactive context, a wake callback re-runs fn() and re-subscribes to sources. This means an explicit .stop() call is rarely needed. When the parent effect re-runs and clears its subscriptions, the inner computed auto-sleeps and releases its source subscriptions automatically.

Keyed signals

A second argument to signal() turns it into a keyed signal scoped to the innermost running computed . The implementation in signal.js tracks the active computed in a module-level currentComputed variable, and stores a per-computed registry in a keyedRegistries WeakMap:

export function signal(initial, key) {
  if (key !== undefined && currentComputed !== null) {
    const owner = currentComputed;
    let registry = keyedRegistries.get(owner);
    if (registry === undefined) {
      registry = { signals: new Map(), accessed: new Set() };
      keyedRegistries.set(owner, registry);
    }
    if (registry.accessed.has(key)) {
      throttledError('duplicate-keyed-signal', /* ... */);
    }
    registry.accessed.add(key);
    const existing = registry.signals.get(key);
    if (existing !== undefined) { return existing; }
    const sig = new Signal(initial);
    registry.signals.set(key, sig);
    return sig;
  }
  return new Signal(initial);
}

Each computed run clears its accessed set before invoking the user's function. After the run completes, any key in the registry that wasn't accessed is stopped and removed:

// Sweep keyed signals that weren't touched this run.
if (registry !== undefined) {
  for (const [k, sig] of registry.signals) {
    if (!registry.accessed.has(k)) {
      sig.stop();
      registry.signals.delete(k);
    }
  }
}

This handles list mappings naturally. signal(false, item.id) inside items.get().map(...) returns the same instance for the same item id across renders. When an item leaves the list, its key is never accessed, so the signal is stopped and the entry removed in the same render cycle.

Reusing the same cached tag instance across renders keeps the reconciler in the cheap bidirectional path. tagNeedsRebuild returns false because the tag still backs its live DOM node, so prefix and suffix matches advance with no DOM mutation. A fresh tag for the same key (when mapFn touches a signal that changed) triggers rebuildNode plus preserve-state.js for that one row. There is no snapshot fast path and no in-place attribute patching.

The registry uses a plain Map for the per-key lookup and a plain Set for accessed-key tracking, so the key can be any value with SameValueZero identity. String, number, symbol, or object reference. Object keys work when the same reference is passed across outer re-runs (e.g. mutating items in place). Immutable update patterns that clone the item object on every change break the match and lose state, so item.id is the safer default. The TypeScript signature reflects this with the exported alias SignalKey = string | number | object | symbol .

Keyed computeds

A second argument to computed() works the same way as for signal() : it scopes the inner computed to the innermost running computed and returns the same instance for the same key across outer re-runs. The implementation stores a separate keyedComputedRegistries WeakMap alongside keyedRegistries .

The fn closure cannot be stored in a Signal directly because Signal.set() treats function-type arguments as updater functions. Instead, the registry entry holds a mutable fnWrapper object and a plain numeric versionSig counter. The inner computed closes over both:

const versionSig = new Signal(0);
const fnWrapper  = { fn };
const inner = computed(() => { versionSig.get(); return fnWrapper.fn(); });

On each outer re-run, if the fn reference changed, the wrapper is updated and the version counter is incremented. The increment triggers the inner computed to re-run with the new fn:

if (existing.fnWrapper.fn !== fn) {
  existing.fnWrapper.fn = fn;
  derivedWriteDepth++;
  try {
    existing.versionSig.set(v => v + 1);
  } finally {
    derivedWriteDepth--;
  }
}
return existing.inner;

If the inner computed is sleeping when versionSig.set() is called, there are no subscribers to notify. The increment is a no-op at that moment. When the outer then calls inner.get() , the wake path re-runs the inner's update() function, which reads fnWrapper.fn . Already updated. And produces the correct value.

A keyedScopeOwners WeakMap records the owner outer computed for each keyed inner signal and computed. Signal.get() checks this map when adding a subscriber: if the subscriber is a user-land effect or computed that is not the owner, a throttled warning fires. DOM-binding effects created via _internalEffect() carry an _isInternal flag and are skipped. Their lifetime is tied to a DOM node that is part of the owner's own render cycle, so a key drop and the corresponding DOM removal happen together. The warning only fires for true escapes: user code capturing a keyed instance in a long-lived effect or computed.

Signal.prototype.transform is a thin wrapper that forwards its optional key to computed , so signal.transform(fn, key) uses the same keyed-computed registry and lifecycle.

The Lifecycle Module

esm / lib / reactive / lifecycle.js

createLifecycle({ element, persist }) is a closure factory. Each call to toElement creates one. The returned object exposes two methods: signalEffect(sig, apply, label) and finalize({...}) .

This module is the only place that decides whether to pause or stop an effect on removal.

Internal state

export function createLifecycle({ element, persist }) {
  const effects = [];                            // the bound effects themselves
  const devIds = [];                             // effect IDs for devtools
  const elementRef = new WeakRef(element);       // shared across every signalEffect
}

Effects are stored directly in the effects array. The stop chain registered by finalize iterates that array and dispatches once based on the captured persist flag, rather than allocating a () => persist ? eff.pause() : eff.stop() closure per signal effect.

signalEffect

signalEffect(sig, apply, label) {
  markNextEffectAsBinding(label);                 // devtools: tag as DOM binding
  const eff = _bindingEffect(sig, val => {
    const el = elementRef.deref();
    if (!el) { eff.stop(); return; }              // element collected; self-stop
    apply(el, val);
  });
  notifyEffectElement(eff._devId, element);       // devtools: link effect to element
  effects.push(eff);
  return eff;
}

The effect runs once immediately when created, applying the initial signal value. On subsequent runs, it dereferences the WeakRef. If the element has been garbage-collected, the effect self-stops. No zombie subscriptions.

_bindingEffect is a lightweight effect that subscribes to exactly one signal. It bypasses track() entirely. No _cleanups iteration, no _reads Set, no currentEffect dance. It just calls fn(sig.value) on each notification. Lifecycle bindings always read exactly one signal, so this fast path collapses the per-run subscribe/resubscribe pair that the general effect machinery does on each fire.

finalize

finalize registers the stop chain and (if needed) the connect callback with dom-tracker. Two branches:

persist: false (default)

On removal, every effect's stop() is called. Permanent teardown. Disconnect callbacks fire once. Connect callback fires once on first insertion only.

persist: true

On removal, every effect's pause() is called. The stop chain rebuilds for the next cycle via reFireAndRegister. On reconnect, every effect's resume() is called and the connect callback re-fires.

The disconnect chain

function registerDisconnectChain() {
  trackForStop(element, () => {
    if (persist) {
      for (let i = 0; i < effects.length; i++) { effects[i].pause(); }
    } else {
      for (let i = 0; i < effects.length; i++) { effects[i].stop(); }
    }
  }, devIds);
  if (onCleared) { addOnStop(element, onCleared); }
  for (const fn of disconnectCallbacks) {
    addOnStop(element, () => fn.call(element, element));
  }
}

trackForStop registers the first link in the chain. The branch on persist is resolved once per removal, then a tight loop dispatches the right method on every effect; we never allocate a per-effect pauseOrStop closure. addOnStop appends to the chain: first onCleared (which resets the tag's #domElement cache), then each user-registered disconnect callback.

The persist rebuild

When persist is true, the chain rebuilds every cycle so disconnect callbacks fire on every removal, not just the first:

if (persist) {
  const reFireAndRegister = () => {
    trackForStop(element, () => {});
    if (onCleared) { addOnStop(element, onCleared); }
    for (const fn of disconnectCallbacks) {
      addOnStop(element, () => fn.call(element, element));
    }
    addOnStop(element, reFireAndRegister);  // self-perpetuates
  };
  addOnStop(element, reFireAndRegister);
}

The connect path

const needsConnect = persist || connectCallbacks.length > 0;
if (needsConnect) {
  let initialConnect = true;
  trackForConnect(element, () => {
    if (initialConnect) {
      initialConnect = false;
    } else {
      if (onReconnect) { onReconnect(); }
      if (persist) {
        for (const eff of effects) {
          eff.resume();
          addOnStop(element, () => eff.pause());
        }
      }
    }
    for (const fn of connectCallbacks) { fn.call(element, element); }
  }, persist);
}

The shared callback-fire loop runs on both first connection and reconnection. Only the reconnect-specific work ( onReconnect , resume + re-pause wiring) is gated on the else branch.

The DOM Tracker

esm / lib / reactive / dom-tracker.js

A shared MutationObserver watches document.documentElement for any subtree mutation. When tracked elements are added or removed, it fires registered callbacks. This is what closes the loop between "element removed from the DOM" and "effects stop, signals unsubscribe."

The entries map

const entries = new WeakMap();
const contentTracked = new WeakSet();
let hasAnyTracked = false;   // one-shot latch: observer short-circuit when nothing was ever tracked

entries is a WeakMap keyed by element. Each entry holds a stop function, an optional connect function, and a persist flag. hasAnyTracked is a single boolean. The observer reads it to skip all per-record work in the common case where nothing in the document is tracked yet.

The observer

function buildObserver() {
  if (observer !== null) { return; }
  observer = new MutationObserver(records => {
    if (!hasAnyTracked) { return; }
    for (const record of records) {
      for (const node of record.removedNodes) { if (!node.isConnected) { stopRemoved(node); } }
      for (const node of record.addedNodes)   { fireConnected(node); }
    }
  });
  observer.observe(document.documentElement, { childList: true, subtree: true });
}

Built lazily on the first trackForStop or trackForConnect call. There is exactly one for the whole document. The hasAnyTracked latch means mutation records are skipped without any per-record work when nothing has ever been tracked.

The stopRemoved call is guarded by if (!node.isConnected) . A node removed and immediately reinserted in the same mutation batch will be connected again when the observer fires, so its effects must not be stopped.

The visit helper

visit(node, fn) handles two cases for a mutation record's node: the node itself might be tracked, or it might be an ancestor of one or more tracked elements. The walk uses document.createTreeWalker so the cost scales with the subtree of the removed node, not with the total number of tracked elements:

function visit(node, fn) {
  const own = entries.get(node);
  if (own !== undefined) { fn(node, own); }
  if (node.nodeType !== 1) { return; }
  const skipComments = own !== undefined;
  const walker = document.createTreeWalker(node, SHOW_ELEMENT_AND_COMMENT);
  for (let el = walker.nextNode(); el !== null; el = walker.nextNode()) {
    if (skipComments && el.nodeType !== 1) { continue; }  // protect LiteralTag/CommentTag anchors
    const entry = entries.get(el);
    if (entry !== undefined) { fn(el, entry); }
  }
}

stopRangeBetween

export function stopRangeBetween(firstNode, endAnchor) {
  if (firstNode === null || firstNode === endAnchor || !hasAnyTracked) { return; }
  const walker = document.createTreeWalker(firstNode.parentNode, SHOW_ELEMENT_AND_COMMENT);
  walker.currentNode = firstNode;
  for (let node = firstNode; node !== null && node !== endAnchor; node = walker.nextNode()) {
    const entry = entries.get(node);
    if (entry !== undefined) { stopOne(node, entry); }
  }
}

Used by the reconciler's clear path. Walks an entire sibling range with a single TreeWalker. Building one walker is dramatically cheaper than building 1000 walkers when rowsSignal.set([]) clears a list with a thousand reactive rows. The shared stopOne(element, entry) helper handles the "clear entry then call stop" sequence used by every dom-tracker teardown path.

API surface

Export Purpose
trackForStop(el, fn, devIds) Register the initial stop function and associated devtools effect IDs.
trackForConnect(el, fn, persist) Register the connect callback. persist controls re-fire and entry survival after removal.
addOnStop(el, fn) Append to the stop chain. No-op if stop is not set.
markContentTracked(el) Flag an element as owning signal-content anchors.
isTracked(el) Does this element have an active stop registration?
isContentTracked(el) Was this element flagged via markContentTracked?
stopTracked(el) Force synchronous teardown. Used by the reconciler for discarded fresh nodes.
stopRemoved(node) Called by the MutationObserver. Calls visit() to find and stop all tracked entries for node or its descendants.
stopRangeBetween(first, end) Stop every tracked entry in a sibling range with one TreeWalker pass. Used by reconcile's clear shortcut.

Removal Flow

An element is removed when something calls element.remove() , parent.removeChild(element) , parent.replaceChildren(...) , etc. The browser fires a mutation record. The shared MutationObserver picks it up.

sequenceDiagram
  participant U as User code
  participant B as Browser
  participant Mo as MutationObserver
  participant T as DOM tracker
  participant L as Lifecycle stops
  participant S as Signals
  U->>B: element.remove()
  B->>Mo: MutationRecord (removedNodes)
  Mo->>T: stopRemoved(node)
  T->>T: visit(node, fn)
  Note over T: For node itself AND any tracked descendants
  T->>T: clearStop(entry, el)
  T->>L: stop()
  Note over L: pauseOrStop for each effect, then onCleared, then disconnectCallbacks
  L->>S: eff.stop() or eff.pause()
  Note over S: Drains _cleanups, removes from subscribers
  1. Browser fires the MutationRecord. removedNodes contains the directly-removed node. The tracked element may be that node or a descendant.
  2. stopRemoved(node). Calls visit(node, fn) which finds the tracked entry for the node or any tracked descendant.
  3. clearStop(entry, el). Deletes entry.stop. If not persisted, also deletes connect and persist. If both halves are gone, removes the entry entirely.
  4. The captured stop function runs. This is the chained closure built via trackForStop and every addOnStop.
  5. For each signal effect: eff.pause() or eff.stop(). Driven by the persist flag. pause() drains _cleanups (unsubscribing from each Signal); stop() does the same and sets destroyed.
  6. onCleared runs. Resets the tag's #domElement cache to null.
  7. Each user disconnect callback runs. In registration order.

Persist Mode

By default, removal is permanent. Effects stop, the tag's #domElement cache clears, and disconnect callbacks fire. If the element is re-inserted later, the effects are gone and the signal subscriptions must be rebuilt by calling toElement() again.

The persist tag option changes this. Effects pause instead of stop. The tag's #domElement restores when the element returns. The disconnect-callback chain rebuilds so it fires on every cycle, not just the first.

This is the pattern for elements that move between containers without losing identity: tabs that swap, modals that hide and reshow, custom elements whose connectedCallback fires multiple times.

sequenceDiagram
  participant U as User code
  participant E as Element
  participant Mo as MutationObserver
  participant L as Lifecycle
  participant Sg as Signals
  Note over E,L: Initial render with persist:true
  U->>E: parent.append(element)
  Mo->>L: fireConnected, first connect cb run
  Note over E: Signal updates, effects re-run normally
  U->>Sg: signal.set(v)
  Sg->>L: scheduled effect runs
  Note over E,L: First removal
  U->>E: parent.removeChild(element)
  Mo->>L: stopRemoved, each eff.pause()
  L->>L: onCleared, disconnectCallbacks
  L->>L: reFireAndRegister installs new stop chain
  Note over E,L: Re-insertion
  U->>E: parent.append(element)
  Mo->>L: fireConnected
  L->>L: onReconnect, eff.resume() for each effect
  L->>L: addOnStop(eff.pause) re-arm for next removal
  L->>L: connect callbacks fire again

The persist invariants

  1. Pause, don't stop. Every signal effect is captured in resumables. On removal, pauseOrStop picks pause(). The effect closure still exists, just unsubscribed.
  2. Disconnect callbacks re-arm. reFireAndRegister installs a fresh stop chain after each removal so the next removal fires them again.
  3. Reconnect resumes. eff.resume() calls run(), which re-tracks subscriptions and applies the current signal value. Any updates that happened during the gap are visible immediately.
  4. Resume wires its own pause. Right after eff.resume(), the lifecycle adds () => eff.pause() to the new stop chain. The cycle continues.
  5. Connect callbacks fire every cycle. On first insertion and on every reconnect.

persist: false vs. persist: true

persist: false persist: true
On removal: effects eff.stop(). Permanent eff.pause(). Temporary
On removal: connect entry Deleted from entries map Survives in entries map
On removal: disconnect callbacks Fire once total Fire on every removal cycle
On reinsert: connect callbacks Do not fire (entry gone) Fire every cycle
On reinsert: signal state Subscriptions gone; tag must be rebuilt eff.resume() reconnects with current value
Memory footprint Lower. resumables is null Higher. Effects and chain survive

Reconciliation

esm / lib / reactive / reconcile.js

Every signal-content update calls reconcile at reconcile.js . The function reorders, inserts, removes, and rebuilds DOM nodes between a pair of comment anchors set up at element construction. Non-arrays are wrapped as [val] before passing in, so the algorithm only handles the array case.

The algorithm is Vue 2 / Inferno style bidirectional reconciliation. Four cheap cases handle the common shapes (no change, prefix match, suffix match, swap, head-to-tail move) with at most one DOM mutation each. Anything that falls through hits a keymap walk from right to left for the mixed middle.

The incoming array is normalized first. flattenIfNeeded(items) only allocates a flat buffer when the input actually contains a nested array. The common already-flat case returns the input unchanged. filterRenderable(items) strips non-renderable items ( null , undefined , false , true , and empty string, the same set isRenderableItem rejects) so the bidirectional pass can index by position. The hot path returns the input array directly with no allocation. itemToNode(item) calls item.getDomElement?.() ?? item.toElement() so a per-key cached tag returns its already-built node.

Keys and node lookup

Keys live on the tag instance, not on the rendered element. signal.mapWithKey(keyOrProp, mapFn) stamps each tag returned from mapFn with a Kensington-internal property ( KENSINGTON_KEY equals '_kensingtonKey' ). The reconciler reads that property via itemKey(item) and pairs each key with its live DOM node through the nodeKeys WeakMap , populated when a fresh node is inserted. The rendered HTML stays free of internal bookkeeping attributes.

function itemKey(item) {
  if (item === null || typeof item !== 'object') { return null; }
  const key = item[KENSINGTON_KEY];
  if (key !== undefined) { return key; }
  // Stable tag instances passed directly (without mapWithKey) get the tag itself
  // as an implicit key. The reconciler can then recognize the same tag across
  // renders without requiring the user to thread an explicit key through.
  if (item._isKensingtonTag === true) { return item; }
  return null;
}

Stable tag instances passed directly (not via mapWithKey ) get the tag instance itself as an implicit key, so reusing the same t.div(...) reference across renders is enough to keep its DOM node. Items that are neither a stamped tag nor a Kensington tag (plain strings, numbers, plain objects) are unkeyed and always build a fresh DOM node.

Clear fast path

Before the main loop, an empty newItems takes a dedicated fast path. A single stopRangeBetween(firstChild, endAnchor) walks every tracked descendant in one TreeWalker pass and stops its effects. Then Range.deleteContents() removes the DOM in one operation.

if (items.length === 0 && startAnchor.nextSibling !== endAnchor) {
  stopRangeBetween(startAnchor.nextSibling, endAnchor);
  const range = document.createRange();
  range.setStartAfter(startAnchor);
  range.setEndBefore(endAnchor);
  range.deleteContents();
  return;
}

Bidirectional matching

After the clear shortcut, the reconciler snapshots the current children into an oldChildren array and runs four pointer indices: oldStart , oldEnd , newStart , and newEnd . On every iteration of the outer loop, it tries four cheap matches in order and falls through to the keymap path only if none apply.

while (oldStart <= oldEnd && newStart <= newEnd) {
  const oldStartNode = oldChildren[oldStart];
  const oldEndNode = oldChildren[oldEnd];
  const oldStartKey = nodeKeys.get(oldStartNode);
  const oldEndKey = nodeKeys.get(oldEndNode);
  const newStartKey = itemKey(items[newStart]);
  const newEndKey = itemKey(items[newEnd]);
  if (oldStartKey === newStartKey && oldStartKey !== undefined) {
    // Prefix match. No DOM op (or one rebuild if the tag is stale).
    oldStart++; newStart++;
  } else if (oldEndKey === newEndKey && oldEndKey !== undefined) {
    // Suffix match. No DOM op.
    oldEnd--; newEnd--;
  } else if (oldStartKey === newEndKey && oldStartKey !== undefined) {
    // Head moved to tail. One insertBefore.
    parent.insertBefore(node, oldEndNode.nextSibling);
    oldStart++; newEnd--;
  } else if (oldEndKey === newStartKey && oldEndKey !== undefined) {
    // Tail moved to head. One insertBefore. The js-framework-benchmark swap test lives here.
    parent.insertBefore(node, oldStartNode);
    oldEnd--; newStart++;
  } else {
    break; // Fall through to the keymap path.
  }
}
  1. Prefix match. Old and new agree at the head. Advance both Start indices. No DOM mutation.
  2. Suffix match. Old and new agree at the tail. Retreat both End indices. No DOM mutation.
  3. Head to tail. The head of the old list now lives at the tail of the new list. One insertBefore moves it. oldStart++ , newEnd-- .
  4. Tail to head. The tail of the old list now lives at the head of the new list. One insertBefore moves it. oldEnd-- , newStart++ . This is the path the js-framework-benchmark swap row test exercises.

Rebuild on stale tag

A keyed match resolves to either the same cached tag (DOM reused as-is) or a fresh tag instance for that key. The fresh case happens when mapWithKey 's per-key inner computed re-ran because mapFn touched a signal that changed. tagNeedsRebuild(item, node) detects it by asking the tag for the DOM it currently backs.

function tagNeedsRebuild(item, node) {
  if (item === null || typeof item !== 'object') { return false; }
  if (item._isKensingtonTag !== true) { return false; }
  if (typeof item.getDomElement !== 'function') { return false; }
  const cached = item.getDomElement();
  return cached !== node && cached !== item;
}

When the tag is stale, rebuildNode captures user-visible state from the old node via preserve-state.js ( captureState ), builds the fresh DOM via item.toElement() , inserts the fresh node before the old, removes the old node (which triggers dom-tracker to stop the old effects), and restores state onto the fresh subtree.

Focus and selection, scrollTop and scrollLeft , input.value , checked , indeterminate , <select> value, and <details> / <dialog> open all survive the swap. This path is dormant for static mapFn implementations (the common case). mapWithKey probes mapFn on first sight of a key, and if it touched nothing reactive the cached tag is a plain entry that never needs rebuild.

Main loop and slow path

When the bidirectional loop ends, three tails remain to handle. The trailing fence (the first oldChildren node after oldEnd , or endAnchor if none survived) is the insertBefore reference for new inserts.

  1. Pure insert tail. If oldStart > oldEnd , every remaining new item is appended before the trailing fence with one insertBefore each. Used when a list grows.
  2. Pure remove tail. If newStart > newEnd , every remaining old child is removed with .remove() plus stopRemoved to stop its tracked effects. Used when a list shrinks.
  3. Mixed middle. Otherwise, build a keymap over the remaining oldChildren range and walk the new range from right to left, so the insertBefore reference is always the previous iteration's node. Matched keys reuse their old DOM (or rebuild via rebuildNode if the tag is stale), unmatched keys build fresh DOM via itemToNode . Old nodes that no new item claimed are removed at the end of the loop.

mapWithKey internals

esm / lib / reactive / map-with-key.js

signal.mapWithKey(keyOrProp, mapFn) is the keyed list mapper that feeds stable tag instances to the reconciler . It is attached to the prototype as Signal.prototype.mapWithKey = mapWithKey and returns an _internalComputed wrapper, not a plain computed . The internal form keeps a nested mapWithKey (the documented recursive-tree pattern) from tripping the computed-in-computed warning, and keeps the wrapper's own reads of user-keyed signals inside each row from tripping the out-of-scope warning.

The probe

The first time a key is seen, buildEntry(item, mapFn) runs mapFn under _runMapWithKeyProbe ( signal.js ). The probe swaps currentEffect to a throwaway probe object, clears currentComputed and inComputedFn , and runs mapFn once to discover whether the row is reactive.

const { result, needsReactive } = _runMapWithKeyProbe(() => mapFn(item));
// needsReactive is true if mapFn read any signal (probe._cleanups non-empty)
// or created any signal(initial, key) / computed(fn, key).

needsReactive is true when mapFn read at least one signal (the probe collected a subscription) or created a keyed primitive. On a positive result the probe's subscriptions are unwound before returning, so the probe never leaves a dangling subscriber.

Static versus reactive entries

The probe result decides the shape of the cache entry. The cache is a plain Map keyed by the user key.

  • Static. When needsReactive is false, the entry is { tag, inner: null, keepAwake: null } . The same tag is returned on every later render. The cost is identical to a plain Map lookup.
  • Reactive. When needsReactive is true, the entry is upgraded to { tag: null, inner, keepAwake } where inner = _internalComputed(() => mapFn(item)) and keepAwake = _internalEffect(() => { inner.get(); }) .

When a tracked signal changes, the inner re-emits a fresh tag. The reconciler's tagNeedsRebuild sees item.getDomElement() !== oldNode and rebuildNode swaps the row's DOM in place via preserve-state.js .

The render pass and sweeping

On each render the outer wrapper iterates the source array, looks up each key, and returns the current tag with entry.tag === null ? entry.inner.get() : entry.tag . Each tag is stamped with its key via stampKey(tag, key) , which writes the internal KENSINGTON_KEY property ( '_kensingtonKey' ) that the reconciler reads back. Keys whose items disappeared from the array are swept. Their inner and keepAwake are stopped so per-row signals and computeds tear down.

Misuse warnings

  • Duplicate keys. Two items resolving to the same key in one render fire throttledError('mapwithkey-duplicate-key', ...) and the first item wins. The duplicate is skipped, so each key always maps to exactly one cached tag.
  • Called inside a reactive context. Calling mapWithKey inside a computed or effect fires throttledWarn('mapwithkey-in-reactive', ...) via _isInReactiveContext() , because the whole per-key registry would rebuild on every outer re-run.

SSR and Hydration

esm / lib / render / hydration.js

On the server (or any environment without a real DOM), reactive subscriptions must not be created. They would have nothing to update and no cleanup path, so they would leak immediately. Both effect() and computed() consult isSSRMode() from ssr.js . The SSR mode counter, _enterSSRMode , _exitSSRMode , and isSSRMode all live in that module.

The SSR bypass

function createEffect(fn, isInternal = false) {
  if (isSSRMode()) {
    return { pause() {}, resume() {}, stop() {} };  // no-op stub
  }
  // ... normal path ...
}
export function computed(fn) {
  if (isSSRMode()) {
    const s = new Signal(fn());     // value snapshot, no subscriptions
    derivedSignals.add(s);
    return s;
  }
  // ... normal path ...
}

Inside an SSR call, effect() returns a no-op stub (the isSSRMode() check lives in createEffect , which effect() calls) and computed() returns a frozen-value Signal. No subscriptions are created in either case. tag.toString() still reads signal values via .get() (which works fine without a current effect) and produces a static HTML snapshot.

renderForHydration

renderForHydration(fn, state, name?, options?) in hydration.js wraps a component for isomorphic rendering. On the server, it calls _enterSSRMode() , invokes fn(state, context) (where context comes from options.context ), calls toString() , embeds the resulting HTML alongside a JSON state block, and calls _exitSSRMode() in finally. The caller is responsible for inserting the resulting HTML into the page.

On the client, renderForHydration produces a placeholder element that registerComponents later replaces with the live DOM version.

context is the non-serialized second argument every component receives. The server passes its own context to renderForHydration ; the client passes its own to registerComponents . The framework stores them in a per-name contextRegistry Map alongside componentRegistry and forwards them on every invocation, including HMR hot-swaps. The state arg remains the only serialized payload.

registerComponents + the JSON block

On the client, registerComponents({ name: fn }, { context }) reads the JSON block embedded by the server render, looks up each registered component by name, and runs fn(state, context) to produce a fresh tag instance. That instance's toElement() creates the live DOM tree with signal effects, which then replaces the SSR-rendered HTML in the document. The optional context is stored per component name and reused for any later HMR re-render.

This is "remove and replace" hydration, not "reuse and attach." The SSR HTML serves time-to-first-paint. The live version takes over once JS is ready.

Hydration scopes

When the client hydrates a component, it does so inside a hydration scope from hydration-scope.js . The scope is a Map keyed by a per-mount id, holding { signals: Map, computeds: Map } keyed by the user-supplied keys. hydrateComponent enters a scope keyed by the SSR mount id, stamps the live nodes with data-k-mount-target , and records the instance. The HMR path ( covered below ) uses the same machinery so hot swaps preserve state.

Inside an active scope, signal(initial, key) (when currentComputed === null ) and computed(fn, key) look up the per-scope map by key and reuse the existing instance, so user-visible values persist across a re-render of the same mount. New signals are created with suppressReactiveCheck so the "signal inside computed" warning does not fire. The read accessor is getCurrentHydrationScope() .

HMR

esm / vite / index.js + hydration.js

The kensington/vite subpath ships a small Vite plugin that wires component HMR without asking the user to add any code in the component itself. The plugin parses each matched source file to an AST ( acorn + magic-string , both declared as optional peer dependencies and loaded lazily), wraps each component export with __kInstrument(name, fn) , and appends an import.meta.hot.accept block that calls hmrReplaceComponent on save. Production builds skip the transform entirely ( apply: 'serve' ).

The Vite plugin

// vite.config.js
import { kensingtonHmr } from 'kensington/vite';
export default {
  plugins: [
    kensingtonHmr({ include: 'src/components/**/*.{js,ts}' }),
  ],
};

include accepts a glob, an array of globs, or a callback (server) => glob | globs | null . The callback form lets adapters like kensington-dev-server source the glob from runtime state that is not known at config time. Internally both forms are normalised to a callback.

Supported export shapes (others silently keep no-HMR behaviour):

  • export function NAME(...) {}
  • export const NAME = function|()=>...
  • export default function NAME(...) {}
  • export default function(...) {} (anonymous; name is the file basename)
  • export default () => ... (name is the file basename)
  • export default NAME (re-export of a local declaration)
  • export { NAME, NAME2, ... } (specifier list)

__kInstrument

The runtime counterpart to the AST rewrite. hydration.js exports __kInstrument(name, fn) as a thin wrapper that:

  1. Allocates a fresh mount id and enters a hydration scope ( _enterHydrationScope(mountId) in hydration-scope.js ). Keyed calls like signal(initial, key) and computed(fn, key) inside the component body look up the per-scope registry and reuse the existing instance if one exists.
  2. Calls the original fn(state) and intercepts the returned tag's toElement method so the live element is stamped with data-k-mount-target=<mountId> and recorded in the per-name liveInstances map.
  3. Exposes the original function via the __kFn property on the wrapper so the Vite plugin's accept handler can read it back unwrapped.

SSR ( isSSRMode() ) and re-entrant calls ( _inHydrationScope() ) skip instrumentation entirely. The wrapper steps aside and just calls the original fn . This keeps server-side renders untouched and avoids double-bookkeeping when hydrateComponent itself owns the scope.

hmrReplaceComponent

On every save, the appended HMR accept block calls hmrReplaceComponent(name, mod.<access>.__kFn) . The function walks the per-name liveInstances set and, for each live instance, performs the swap in place:

for (const inst of [...set]) {
  // 1. Detached? Drop and dispose its hydration scope.
  if (!inst.mountNodes[0]?.isConnected) {
    _disposeHydrationScope(inst.mountId);
    dropInstance(name, inst);
    continue;
  }
  // 2. Capture user-visible DOM state (focus, selection, scroll,
  //    input value, checked, indeterminate, <select> value, ...)
  const captured = captureState(inst.mountNodes[0]);
  // 3. Re-render inside the SAME hydration scope.
  //    Keyed signal/computed instances persist; their values survive.
  _enterHydrationScope(inst.mountId);
  const result = newFn(inst.state);
  _exitHydrationScope();
  // 4. Replace nodes in place. dom-tracker stops effects on the
  //    discarded DOM via the MutationObserver, automatically.
  inst.mountNodes[0].replaceWith(...result.map(el => el.toElement()));
  // 5. Restore the captured state onto the fresh subtree.
  restoreState(newNodes[0], captured);
}

Hydration scopes vs. computed-keyed registries

Both mechanisms make signal(initial, key) and computed(fn, key) return the same instance across re-runs of an enclosing scope. They differ in lifetime:

  • Keyed registries inside a computed. Sweep unaccessed keys at the end of every run. An item removed from a list takes its keyed signals with it, automatically.
  • Hydration scopes. Do NOT sweep. Stability comes from the mount id, not from access tracking. A scope is disposed only when the mount is removed via _disposeHydrationScope , at which point every signal and computed in it is stopped. Re-rendering a component during a hot-swap intentionally keeps every keyed signal alive even if the new module doesn't read it.

SSR + HMR parity

SSR-hydrated components participate in HMR alongside client-only ones. After registerComponents runs hydrateComponent on an SSR mount marker, the live nodes are stamped with data-k-mount-target=<mountId> and recorded in liveInstances with the same shape as client-only mounts. hmrReplaceComponent walks both kinds uniformly, so an edit to a component file hot-swaps every instance regardless of how it was mounted.

Live signals

esm / live / index.js

The kensington/live subpath ships a small server/client runtime that lets one named primitive synchronize across browsers. Three public entry points all reachable from the unified path: liveSignal(initial, name, options?) for shared component code, connectLive(opts?) for the client, and liveServer(opts?) for the server. Two node-only peer deps ( ws , better-sqlite3 ) are loaded lazily so the unified path works in browser bundles without pulling them in.

Transport registry. state.js

state.js owns the per-process current transport reference and the public liveSignal() factory. Both client.js and server.js install themselves via _registerTransport(transport) . The factory reads currentTransport at call time and delegates to transport.getOrCreateSignal(name, initial, opts) . With no transport registered, it returns a placeholder _signal(initial) tagged with _isLivePlaceholder and adds an entry to a module-level pending set. Pre-upgrade reads and writes both go through the default Signal.prototype behavior, updating only the placeholder's local value.

When _registerTransport(transport) runs, it drains the pending set and walks each placeholder through upgradePlaceholder(rec, transport) , which looks up the registry-backed signal via transport.getOrCreateSignal(name, placeholder.value, opts) (so any pre-upgrade local write seeds the registry entry when the name is fresh), installs an internal-effect mirror that pipes real → placeholder via _setFromRemote , and rewires placeholder.set and placeholder.stop to forward to real . The user-held reference is preserved across upgrade. If the registry already holds a value for the name, the mirror's first run overwrites the placeholder with the authoritative value and the pre-upgrade local write is silently lost.

validateCanWrite(value) inside the factory rejects invalid options.canWrite values eagerly (throws a TypeError at the call site rather than waiting for the server to reject the write). The valid forms are 'any' , 'server-only' , or a function.

Wire protocol. protocol.js

protocol.js is the single source of truth for message types and the encode/decode pair.

// Client → Server
{ type: 'subscribe',   name, persist? }
{ type: 'unsubscribe', name }
{ type: 'set',         name, value, ifLamport?, opId? }
// Server → Client
{ type: 'snapshot',    values: { [name]: value }, lamport }
{ type: 'update',      name, value, lamport }
{ type: 'batch-update', updates: Array<{ name, value, lamport }> }
{ type: 'set-ok',      name, lamport, opId }
{ type: 'set-fail',    name, opId, reason, value, lamport }
{ type: 'error',       name, reason }

Ordering is Lamport last-write-wins; the server is the sole authority for lamport assignment. Direct .set(value) writes omit both ifLamport and opId ; the server applies them unconditionally subject to canWrite . CAS writes ( .set(fn) ) carry both: the server applies only when its current lamport for the name equals ifLamport . opId correlates the response ( set-ok or set-fail ) back to the originating client. subscribe carries persist: true only when the client explicitly opted in; the field is omitted otherwise so the server can distinguish a positive declaration from a default-follow.

Client transport. client.js

client.js exports connectLive(opts) which constructs a ClientTransport , registers it via _registerTransport , and calls transport.connect() before returning. One transport instance per tab, multiplexed across every liveSignal .

Instance state

  • signals . Map<name, Signal>. Backs the auto-resubscribe-on-reconnect loop.
  • initialValues . Map<name, T>. First-call initial for duplicate-name mismatch warnings.
  • persistFlags . Map<name, boolean>. Re-sent on every SUBSCRIBE.
  • lastSeen . Map<name, number>. Lamport of the last applied update; used to drop stale broadcasts and as ifLamport for CAS.
  • outbound . Queue of frames pending an open socket (used both while disconnected and while sendPaused is set).
  • pendingCas . Map<opId, { name, fn, attempts, resolve, reject }>. The set(fn) retry loop suspends here.
  • unserializableWarned . Set<name> for the once-per-name JSON-stringify warning.
  • status . Reactive Signal<ConnectionStatus> . Mirrors the WebSocket lifecycle.
  • reconnectDelay , reconnectAttempts . Exponential-backoff state. Reset by resetReconnectState() .
  • closed . Set true by close() ; terminal.
  • manuallyDisconnected . Set true by disconnect() ; reverses on reconnect() . The close-event auto-reconnect bails out while set.
  • sendPaused . Set true by pauseSend() ; send() diverts to outbound while set.

connect()

Sets status = 'connecting' , allocates a new WebSocket(this.url) (bailing to scheduleReconnect if the constructor throws), and wires three listeners.

  • open. setStatus('connected')resetReconnectState() → walk this.signals.keys() and re-send a subscribe frame for each → drain outbound . Both loops go through rawSendOnSocket(ws, msg) so onFrame fires.
  • message. Delegates to handleMessage(raw) .
  • close. Nulls this.ws and calls scheduleReconnect() .
  • error. Logged via console.error . The reactive status signal covers the connection state.

scheduleReconnect()

Bails when closed or manuallyDisconnected is set. Compares reconnectAttempts against reconnectOpts.maxRetries ?? Infinity ; on exhaustion, sets status to 'disconnected' and stops scheduling. Otherwise sets status to 'reconnecting' and queues a setTimeout(connect, delay) . reconnectDelay doubles up to maxDelay after each scheduled attempt.

handleMessage(raw)

Decodes the JSON, fires notifyFrame('in', msg) , then dispatches on msg.type :

  • snapshot . Iterates msg.values ; for each pair applies sig._setFromRemote(value) and sets lastSeen.set(name, msg.lamport ?? 0) .
  • update / batch-update . Both dispatch to applyRemoteUpdate(name, value, lamport) , which drops stale broadcasts ( lamport <= seen ) and otherwise calls _setFromRemote .
  • set-ok . Updates lastSeen , takes the pending write entry via takePendingWrite(opId) , resolves its promise.
  • set-fail . Takes the pending entry, applies the server's authoritative value to the local Signal via _setFromRemote , bumps lastSeen , and either retries ( reason === 'conflict' on a CAS write) via retryCas(pending) or rejects the promise with a LiveSetRejected Error.
  • error . canRead subscribe rejection. Logged via console.error since there is no per-call surface to route to.

getOrCreateSignal(name, initial, options)

On a cached hit, runs the duplicate-name mismatch warnings and returns the existing Signal. On a miss, builds a fresh Signal and wires four hooks before returning it:

  1. Wrap sig.set . The wrapper detects typeof valueOrFn === 'function' and dispatches to casUpdate(...) . The direct path checks checkSerializable , calls the original origSet , and sends { type: 'set', name, value } .
  2. Wrap sig.stop . After origStop runs, calls this.unsubscribe(name) to tear down the server-side subscription too.
  3. Install _onZeroSubscribers / _onFirstSubscriber . When local subscribers reach zero, send MSG_UNSUBSCRIBE . When they go from 0 → 1, send MSG_SUBSCRIBE . The two hooks are how the Signal class's sleep/wake cycle reaches the transport.
  4. Register and subscribe. signals.set(name, sig) followed by send(buildSubscribeMsg(name, persist)) .

CAS. casUpdate / sendCasWrite / retryCas

casUpdate(name, sig, origSet, fn) runs fn(sig.value) synchronously for the optimistic apply (catching throws and rejecting the returned Promise), checks serializability, calls origSet(initialNext) so subscribers see the new value immediately, and returns a Promise that sendCasWrite will eventually resolve or reject.

sendCasWrite(pending, value) allocates a fresh opId , registers the pending entry in pendingCas , and sends { type: 'set', name, value, ifLamport, opId } with ifLamport = lastSeen.get(name) ?? 0 .

retryCas(pending) bumps pending.attempts ; if it exceeds MAX_CAS_RETRIES (8) the promise is rejected with a "high write contention" message. Otherwise it re-runs pending.fn(sig.value) against the freshly-applied server value, re-checks serializability, _setFromRemote s the new local value, and reissues via sendCasWrite .

Lifecycle methods

  • close() . Sets closed = true , calls dropSocket() , rejects every pending CAS with "transport closed", and transitions to 'disconnected' . Terminal.
  • disconnect() . Sets manuallyDisconnected = true , calls dropSocket() , transitions to 'disconnected' . Reversible via reconnect() .
  • reconnect() . Calls dropSocket() , resets backoff, clears manuallyDisconnected , transitions to 'reconnecting' , and queues a connect() via queueMicrotask so any same-tick caller-side state changes (display-name edit, etc.) land in the new URL.
  • pauseSend() . Sets sendPaused = true . send() diverts outgoing frames to outbound . Reads continue to flow.
  • resumeSend() . Clears the flag and flushes outbound via rawSendOnSocket in FIFO order.
  • unsubscribe(name) . Drops the name from every internal Map and sends MSG_UNSUBSCRIBE . The local Signal stays valid for reads.

dropSocket() is the shared "clear timer + close ws + null ref" helper used by all three of close / disconnect / reconnect .

Server runtime. server.js

server.js exports the liveServer(opts) factory. The function is async because the sqlite store loads via dynamic import; memory awaits uniformly.

Closure state

  • registry . Map<name, { value, lamport }>. The canonical in-memory store.
  • subs . Map<name, Set<socket>>. Client subscribers per name.
  • nameSubscribers . Map<name, Set<callback>>. Server-side observers (the cb registered when a server-side liveSignal is created).
  • initialValues . Map<name, T>. First-call initial for duplicate-name warnings.
  • persistPolicy . Map<name, boolean>. First-declaration-wins. Disk-warmed names start blank; the next declaration sets policy.
  • canWritePolicy . Map<name, predicate>. Per-name predicates registered by server-side liveSignal(..., { canWrite }) .
  • transientDropTimers . Map<name, Timeout>. Grace-period drop timers for persist:false names that hit zero subscribers.
  • serverSignals . Map<name, Signal>. Cached server-side Signal instances built by makeServerSignal .
  • lamport . The monotonic counter assigned by commitWrite .
  • attachedWss . The WebSocketServer from attach() . Null on the Bun path. close() tears it down.
  • heartbeatTimer . The setInterval handle for the heartbeat ping loop.
  • pendingBroadcasts , flushScheduled . Outbound coalescing buffer flushed via queueMicrotask .

commitWrite / applySet

Every write funnels through commitWrite(name, value, fromSocket) : increment lamport , update registry , persist if persistPolicy says so, broadcast(name, fromSocket) (excludes the originator), then notifyObservers(name, value) for the server-side observer set.

applySet(name, value, fromSocket) is the thin wrapper that runs checkSerializable first. It is called from the wrapped server-side sig.set and from live.set . handleClientSet bypasses applySet and goes straight to commitWrite after running its own canWrite / CAS / serializability checks.

handleClientSet / replyToClientSet / rejectClientSet

handleClientSet is the entry point for inbound MSG_SET . It assembles the { prev, next } transition, runs the global canWrite and any per-signal predicate, runs the CAS check when ifLamport is present, runs checkSerializable , then calls commitWrite and replyToClientSet(...true) .

rejectClientSet(sock, name, msg, reason) factors out the "rejection" path. CAS writers ( opId present) receive a typed MSG_SET_FAIL with the server's authoritative value and lamport. Non-CAS writers receive a best-effort MSG_ERROR instead.

replyToClientSet(sock, name, opId, ok, reason) returns early when opId === undefined (fire-and-forget writes), otherwise sends the matching set-ok or set-fail frame.

Broadcast batching

broadcast(name, exclude) pushes { name, exclude } onto pendingBroadcasts and schedules a queueMicrotask(flushBroadcasts) (guarded by flushScheduled ).

flushBroadcasts() groups the queued entries by destination socket. Sockets that pick up exactly one update receive a plain MSG_UPDATE ; sockets that pick up two or more receive a single MSG_BATCH_UPDATE carrying the array. The grouping happens at flush time, not at broadcast time, so a burst of server-side writes in one tick coalesces correctly.

Transient-drop machinery

Names declared persist: false are dropped from the registry TRANSIENT_GRACE_MS (30s) after the last subscriber leaves. The grace period covers brief reconnects and the local sleep-wake cycle.

  • scheduleTransientDropIfNeeded(name) . Bails if the policy is not false , if any subscriber remains (via hasAnySubscriber ), or if a timer already exists. Otherwise sets a setTimeout that is unref() -ed so it doesn't pin the event loop.
  • cancelTransientDrop(name) . Clears the timer; called on every fresh MSG_SUBSCRIBE and every getOrCreateSignal on the server.
  • dropTransient(name) . The timer body. Re-checks hasAnySubscriber (in case a subscriber arrived during the grace window) and tears down all per-name maps + the store entry.
  • hasAnySubscriber(name) . True when subs[name] or nameSubscribers[name] has anyone. The drop scheduler and dropTransient itself both consult it.

attach(httpServer)

Resolves the ws peer dep via createRequire anchored at the user's process.cwd() (so link: -style installs find it), with a fallback to a dynamic import('ws') . Throws a clear error when neither works.

Constructs a new WebSocketServer({ server: httpServer, path }) , retains it on attachedWss , and wires per-connection handlers:

  • sock._kensingtonAlive = true plus a sock.on('pong', ...) listener that re-flips the flag. The heartbeat below uses this.
  • await onSocketOpen(sock, req) which runs the user's onConnect (catching throws and logging) and stores the returned ctx on the per-socket socketState .
  • sock.on('message', ...) delegates to onSocketMessage , which dispatches on msg.type to the subscribe/unsubscribe/set handlers.
  • sock.on('close', ...) calls handleSocketClose which removes the socket from every subs[name] , schedules transient drops, runs the user's onSocketClose callback, and clears the per-socket state.

Heartbeat. When heartbeatInterval !== false , a setInterval walks wss.clients every interval. For each socket, if _kensingtonAlive === false (the previous ping never got a pong), terminate() is called. Otherwise the flag is reset to false and a sock.ping() is sent. The pong listener flips the flag back to true. The interval is unref() -ed.

LiveServer handle. get / set / list / policyOf / delete

  • get(name) . registry.get(name)?.value .
  • set(name, value, options) . Calls recordPersist then applySet(name, value, null) . Server-side writes bypass canWrite .
  • list(prefix) . Walks the in-memory registry (NOT the persistence store) and returns [[name, value], ...] for every name starting with the prefix. Transient entries are visible.
  • policyOf(name) . Returns persistPolicy.get(name) : true / false / undefined .
  • delete(name) . Cancels the transient drop, deletes from every map, and drops the cached server Signal. Does not notify subscribers. Use set(name, null) instead when subscribers must observe the removal.

Server-side liveSignal. makeServerSignal

When liveSignal(initial, name) runs on the server (via serverTransport.getOrCreateSignal ), it returns a long-lived Signal cached in serverSignals . The Signal is built by makeServerSignal , which wraps sig.set so writes go through applySet (registry + broadcast + observer notification), and registers a cb => sig._setFromRemote(value) observer in nameSubscribers[name] . The cb makes the local Signal mirror client and server-initiated writes; _setFromRemote short-circuits when the value already matches, so the writer's own callback does not double-notify its subscribers.

Calling sig.stop() removes the cb, drops the cache entry, and (if the observer set is now empty) triggers scheduleTransientDropIfNeeded(name) .

close()

Tears down the WebSocket server first to stop in-flight broadcasts from racing on a half-closed socket. For each attachedWss.clients calls terminate() (skipping the close-handshake wait), then wss.close() . Clears the heartbeat interval. Drops every transient-drop timer. Clears nameSubscribers and serverSignals . Closes the persistence store.

Persistence adapters

Both adapters expose get(name) , set(name, value) , delete(name) , all() , and close() . The server warms the in-memory registry from store.all() at boot.

  • memory.js . Map -backed. Synchronous. Default backend.
  • sqlite.js . Lazy-loads better-sqlite3 via createRequire anchored at the user's cwd . Maintains an in-memory mirror so reads stay sync. Writes are debounced via setTimeout (default 250ms) and grouped into a single transaction; the flush is unref() -ed so a pending debounce never pins the event loop.

Where to look

If you're working on... Look at...
Wire format, message types, encode/decode protocol.js
The no-transport fallback or the liveSignal factory state.js
Reconnect backoff, CAS retry, frame inspection client.js . scheduleReconnect , retryCas , notifyFrame .
canWrite enforcement, CAS check, write commit server.js . handleClientSet + commitWrite .
Broadcast batching, batch-update grouping server.js . broadcast + flushBroadcasts .
Transient-drop grace period server.js . scheduleTransientDropIfNeeded , dropTransient , TRANSIENT_GRACE_MS .
Heartbeat / dead-socket detection server.js . The setInterval inside attach() plus the per-socket _kensingtonAlive flag.
Server-side liveSignal as a reactive subscription server.js . makeServerSignal + serverTransport.getOrCreateSignal .
Sqlite persistence and the debounced flush sqlite.js

Validation dispatch

Every validation report in the library funnels through one function in show-invalid.js . showInvalid(message, validationLevel, logger) implements the three-level contract that the rest of the codebase relies on.

The function builds an Error from the message and passes it through filter-stack.js before either logging or throwing, so the surfaced stack points at the caller rather than at internal frames.

Warning throttling

warnings.js owns the runtime diagnostics used by the reactive layer. It exports throttledError , throttledWarn , and the test helper _resetWarningThrottle . These back the repeated diagnostics fired from signal.js and map-with-key.js . Loop detection, invalid usage, duplicate keys.

Each diagnostic is keyed. A warnLastSeen Map records the last time each key fired. A repeated key is suppressed until WARN_THROTTLE_MS (1000ms) has elapsed, so a hot loop surfaces the problem once per window instead of flooding the console. _resetWarningThrottle clears the Map for tests.

Stack frame stripping

filter-stack.js strips Kensington-internal frames from an Error so warnings and validation errors point at the caller's code rather than at library plumbing. It is shared by show-invalid.js and by the throttled functions in warnings.js .

Node.js

In Node the module reads import.meta.url , which is a file: URL pointing at the real file on disk. It derives the esm/ directory from that URL and removes every stack frame whose location includes that path. User frames live elsewhere, so they survive.

Browser bundle

In a browser bundle Kensington and user code share the same bundle URL, so there is no reliable way to tell an internal frame from an external one. Filtering by the bundle URL would strip the user's own frames too, leaving only native-code frames. The filter therefore preserves the full stack whenever it cannot make that distinction, including IIFE bundles where import.meta is absent entirely.

Devtools hook

devtools.js implements the window.__KENSINGTON_DEVTOOLS__ hook. enableDevtools() activates the hook once. Subsequent calls are no-ops, and calling it on the server only logs a warning.

The hook object exposes:

Signal IDs and effect IDs are monotonically increasing integers assigned at creation time. A signalGcRegistry (a FinalizationRegistry , or a no-op stub where the constructor is unavailable) removes devtools entries for signals that are garbage-collected without an explicit .stop() call.

When a signal reaches zero subscribers its ID is added to a pendingZeroSubscribers Set and scheduled for removal on a queueMicrotask . A re-subscription in the same turn cancels the removal, which is what keeps signals in the panel across a drag-reorder pause and resume. A computedSigs WeakSet records which signals were created via computed() so a sleeping computed can be restored with isComputed: true when it wakes.

markNextEffectAsBinding(label) sets a one-shot flag that routes the next created effect into hook.bindings instead of hook.effects , tagged with the given label. This is how DOM-binding effects are distinguished from user effects in the panel.

State preservation

preserve-state.js captures and restores user-visible DOM state across a node replacement. captureState(root) walks the subtree and returns a snapshot. restoreState(root, state) applies it to a freshly built subtree. It is used by rebuildNode in reconcile.js when a mapWithKey per-key inner re-emits a fresh tag, and by hmrReplaceComponent in hydration.js when a live mount is re-rendered.

What is preserved

The mapping between old and new nodes is positional. It assumes the tree shape is identical between renders, which holds when only signal references differ. State for a position that does not resolve on the new tree is silently dropped.

What cannot be preserved

Some state lives only on the original element instance and is lost on replacement.

Cross-module duck-typing

A page can end up loading more than one copy of the Kensington module, for example when a dependency bundles its own copy. An instanceof check against this copy's classes would reject tags and signals built by the other copy. To accept them, the library brands its prototypes with marker properties and checks the marker instead.

Tag markers

In content-tag.js , ContentTag.prototype._isKensingtonTag = true and ContentTag.prototype._isKensingtonContentTag = true . The content-only marker is set on ContentTag and its subclasses. literal-tag.js and comment-tag.js each set _isKensingtonTag = true on their own prototype but not the content marker.

The local helper isKensingtonTag(c) checks c._isKensingtonTag === true and replaces the direct class checks in content validation. The separate _isKensingtonContentTag marker lets toElement recurse only into content tags, for example to pass the _inheritPersist option down to child ContentTag nodes.

Signal marker

In signal.js , Signal.prototype._isKensingtonSignal = true , and isKensingtonSignal(v) checks v._isKensingtonSignal === true . This is why a Signal from a different module copy is accepted anywhere a value is accepted, including by attributeValueIsValid .

Slim build via Proxy

The full bundle ships the generated Kensington class, which declares a method for every HTML, SVG, and MathML element along with that element's attribute spec data. The slim build replaces that generated class with a hand-written Proxy in kensington-slim.js . It carries no per-element attribute spec data, so it is about 5x smaller minified (roughly 148 KB down to roughly 27 KB), and signal-only consumers tree-shake it down to about 1.5 KB.

Why a Proxy

On the full build every tag method is a generated class field. On the slim build the constructor returns new Proxy(this, ...) so that no tag methods exist up front. A property access like t.div is resolved dynamically by the Proxy's get trap. The trap checks real instance members first (via Reflect.has ), then looks the property up in a small tag-info.js table that maps a method name to a single-letter type code, then builds and caches a createTag closure for it. Because nothing is generated per element, the class body stays tiny.

Resolved closures are memoized in a tagCache , so repeated access to the same tag is a plain map lookup after the first resolution. Real instance methods are bound in the constructor so destructuring such as const { div } = t works the same as on the full build.

// esm/kensington-slim.js (abridged)
return new Proxy(this, {
  get(target, prop, receiver) {
    if (Reflect.has(target, prop)) {
      return Reflect.get(target, prop, receiver);
    }
    if (typeof prop !== 'string') { return undefined; }
    const cached = tagCache[prop];
    if (cached !== undefined) { return cached; }
    const info = tagInfo[prop];
    if (info === undefined) { return undefined; }
    // entry is a bare type code, or [code, tagName] when they differ
    const [tagType, tagName] = Array.isArray(info) ? info : [info, prop];
    const fn = target.createTag(tagName, opts.Klass, { ... });
    tagCache[prop] = fn;
    return fn;
  },
});

Forced validationLevel: off

The slim build ships no attribute spec data, so runtime validation is impossible. The constructor throws if validationLevel is anything other than 'off' . The slim build does still ship the set of camelCase attribute names ( camelCaseNames ) so that getAttrName preserves case for SVG attributes such as viewBox rather than kebab-casing them.

How it is produced

The slim class itself is hand-written. The lookup table it reads is generated. build-tag-info.js emits tag-info.js as part of the normal generator run, mapping each method name to a type code (and, where the method name differs from the element name, to the element name too). The bundle swap happens in the rollup config at build-browser.js . A slimPlugin rewrites four source modules during the slim build. esm/kensington.js resolves to esm/kensington-slim.js , esm/attributes.js becomes a stub that exports only __slim__ and camelCaseNames , and the devtools and stack-filter modules become no-op stubs. The entry point stays index.js , so the swap is invisible to consumers.

The tree-shaking win comes from index.js , where the shared instance is created with a /* @__PURE__ */ annotation on new Kensington() . Combined with the package's sideEffects marking, a bundler can drop the class entirely for a consumer that imports only the reactive primitives. The slim bundles are exposed through the ./dist/slim and ./dist/slim/min package exports.

CLI. html-to-kensington

The package ships a kensington binary, wired through the "bin" field of package.json to html-to-kensington.js . It reads HTML and prints the equivalent Kensington method-call code. The entry point also handles --help , argument validation, and the --copy ( -c ) flag.

Input sources

HTML can arrive three ways. A file path argument is read with readFileSync . A pipe or redirect is read from stdin as a stream. An interactive terminal prompts for a paste and uses bracketed paste mode, where the terminal wraps the pasted text in ESC[200~ and ESC[201~ so the reader knows exactly when the paste ends without requiring Ctrl+D. All of this lives in read-html.js .

The conversion pipeline

flowchart LR
  A[HTML input] --> B[parse5]
  B --> C[convert-html.js]
  C --> D[node-to-code.js per node]
  D --> E[attrs-to-code.js]
  D --> F[formatter.js]
  F --> G[stdout]
  F --> H[clipboard.js --copy]

convert-html.js parses the input with parse5. A full document (one that starts with a doctype or an <html> tag) is parsed with parse , and a fragment is parsed with parseFragment . It then walks the root nodes and delegates each to node-to-code.js . A document with a doctype on its root <html> element converts to htmlWithDocType .

node-to-code.js converts a single parse5 node into a Kensington method call string. Text nodes become JSON string literals (blank text is dropped), comment nodes become t.inlineComment(...) , and element nodes become t.tagName(attrs, content) . SVG element names are restored to their correct case via a lookup table. Children are converted recursively. The function decides between an inline single-line call and a multi-line array of children based on the formatter's line width.

attrs-to-code.js converts a parse5 attribute list into a JS object literal string. It groups attributes that share a first hyphen segment so that data-* and aria-* attributes can use the nested object notation, expands the style attribute into an object, and converts kebab-case names to camelCase. Boolean attributes with an empty value render as name: true .

Formatting and output

formatter.js detects a formatter in the current working directory. It tries Prettier first, then ESLint, then falls back to an identity pass. The detected tool supplies a maximum line length (Prettier's printWidth or ESLint's @stylistic/js/max-len ), which the converter uses to decide where to break lines, and it formats the generated code before it is printed.

The formatted result is written to stdout. With --copy or -c , it is also sent to the system clipboard by clipboard.js , which shells out to pbcopy on macOS, clip on Windows, and xclip on Linux. A failed clipboard copy is silently ignored so the printed output is never blocked.

Invariants

The rules that hold across every code path. Violations are bugs.

  1. validationLevel: 'off' never throws on runtime input. All validation routes through show-invalid.js , which is a no-op at 'off'. Only hard invariants (createTag called with a non-string tagName, etc.) throw unconditionally.
  2. Signal values are accepted everywhere a plain value is accepted. attributeValueIsValid returns true for Signals without inspecting them. Resolution happens at render time.
  3. .value and .toJSON() do not subscribe; .get() and .toString() do. The asymmetry is intentional. Use .value inside an effect when you need the current value but do not want to create a dependency.
  4. The persist mechanism lives entirely in lifecycle.js . No other file decides between pause() and stop(). dom-tracker knows about persist only to decide whether to preserve the connect/persist entry fields after stop-cleanup.
  5. The reconciler never patches existing nodes in place. A matched key resolves to either the same cached tag (DOM reused as-is) or a fresh tag (a full rebuild via rebuildNode plus preserve-state.js ). Reactive updates flow through _bindingEffect subscriptions on the cached tag, not through per-render attribute diffs.
  6. Effects batch via microtasks; computed updates are synchronous. Multiple .set() calls in the same turn coalesce into one effect re-run. Computed signals see consistent inputs because their updates happen inline with the write.
  7. The DOM tracker has exactly one observer for the whole document. Built lazily on the first trackForStop or trackForConnect call.
  8. WeakRef is the GC safety net for signal effects on detached elements. If an element is never inserted and is garbage-collected, the next signal write triggers an effect that finds ref.deref() returning undefined and self-stops.
  9. visit() does not return early when it finds the node itself. It continues to check trackedRefs for descendants. This ensures child effects are paused or stopped with the parent.
  10. _internalEffect is for library-internal use only. It skips the effect-in-effect and effect-in-computed guard checks and flags the run as a DOM binding. Callers are lifecycle.js (via _bindingEffect ), literal-tag.js , comment-tag.js , and map-with-key.js .

Where to look

If you're fixing a bug or adding a feature, here's where the change probably belongs.

If you're working on... Look at...
A new attribute type or validation rule validate.js . Either attributeValueIsValid or validateAttributeByType
HTML output formatting (indentation, encoding) serialize.js + stringify-content-array.js
DOM property vs attribute, event handler wiring content-tag.js . The toElement dispatch
Signal subscription semantics (.get, .set, .value) signal.js . The Signal class
Effect lifecycle (pause, resume, stop, batching) signal.js . effect(), _internalEffect(), createEffect(), flush()
Persist mode (pause on removal, resume on reconnect) lifecycle.js . The entire file
When effects stop or connect callbacks fire dom-tracker.js . stopRemoved and fireConnected
Signal-array DOM patching reconcile.js . The bidirectional pass and tagNeedsRebuild
SSR or hydration behavior hydration.js + the SSR mode counter in ssr.js
A new tag-class flavor (e.g. for custom output) . Extend ContentTag
Live signal transport, protocol, persistence client.js for client transport. server.js for the registry + broadcast multiplexer + heartbeat. protocol.js for the wire format. state.js for the process-wide transport handle.
Generated Kensington class behavior build-javascript.js . The template that emits esm/kensington.js