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.
Outputs HTML strings or live DOM nodes from the same code
Comprehensive TypeScript definitions
No build step required. Works in Node, Deno, and the browser
Easy for AI to implement with an extensive AGENTS.md file
Signals work as content, as attribute values, inside
style
objects, and in the
prop
key. Calling
.toElement()
wires up all updates.
Two output modes, one codebase
.toString()
renders an HTML string for server-side rendering or static generation.
.toElement()
builds a live DOM element and wires signal subscriptions automatically. The same tag instance works both ways.
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.
Why Kensington?
vs tagged template literals
Tagged template literal libraries like
htm
are lightweight and familiar, but TypeScript can only see the outer string, so it cannot validate attribute names or values inside the template. Because markup is structured data, Kensington also handles camelCase-to-kebab conversion, boolean attributes, and
class
as an array — things template-based libraries need runtime string parsing to support.
htm (tagged template)
// TypeScript sees a plain string. No attribute checking inside.
const html = htm`<input typ="checkbox">`;
// ^^^ typo, no error raised
Kensington
// t.input knows the InputAttributes interface
t.input({ typ: 'checkbox' });
// TypeScript: 'typ' does not exist on InputAttributes
vs JSX
JSX offers similar structure but requires a compiler and a framework pragma. Kensington works anywhere JavaScript runs, such as Node.js, Deno, Bun, and the browser, with no configuration.
vs React / Vue / Angular / SolidJS
Full front-end frameworks are designed around a component lifecycle, a virtual DOM or fine-grained reactivity graph, a router, and a build pipeline.
Kensington is useful when that scope is more than you need. It generates HTML strings on the server with no runtime dependency, produces live DOM nodes in the browser with no virtual DOM overhead, and works inside Web Components or vanilla JS projects without committing to a framework. The 2.0 signals build adds fine-grained reactivity for the cases where you do need live updates, without pulling in the bathwater with the baby.
vs DOM methods
document.createElement
is verbose and browser-only. Kensington's
.toElement()
produces live DOM nodes, so the same component can render an HTML string on the server and a live DOM node in the browser from the same code.
vs hyperscript / h
Hyperscript-style APIs like
h('div', attrs, children)
(used in React, Vue render functions, and standalone hyperscript libraries) take the element name as a string, so TypeScript can only type the attribute argument as a generic object. Kensington exposes a dedicated method for each element, so TypeScript knows exactly which attributes are valid for
t.input
versus
t.select
versus
t.a
.
Hyperscript
// attrs typed as Record<string, any>, no element-specific checking
h('input', { typ: 'checkbox' });
// ^^^ typo, no error raised
Kensington
// t.input knows the InputAttributes interface
t.input({ typ: 'checkbox' });
// TypeScript: 'typ' does not exist on InputAttributes
vs Handlebars / EJS / Nunjucks
String-based server-side renderers use a separate syntax in
.hbs
,
.ejs
, or
.njk
files. That syntax lives outside TypeScript's type system, so attribute names, values, and variable references are all unchecked. Kensington is plain JavaScript, so you get the full language for loops and conditionals, your editor's autocomplete for attributes, and TypeScript errors when something is wrong.
Building HTML
Elements & content
Every HTML, SVG, and MathML element is available as a method on
t
. All call forms work:
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
.
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.
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.
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.
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.
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.
index.ts
1t.tr(t.td('Name'))
2t.tr(t.div('Name'))
TS2345
Argument of type DivTag is not assignable to parameter of type
TdTag | ThTag
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.
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
index.js
1t.main({class:'mob'})
mobile-containercontainers.css
modal-bodyCDN
modal-backdropCDN
Diagnostics
index.js
1t.main({class:'contaner'})
Unknown CSS class 'contaner'
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
Add the recommended config to your
eslint.config.js
:
import kensington from 'kensington-eslint-plugin';
export default [
kensington.configs.recommended,
// ...your other configs
];
Error
counter.js
1const total = computed(() => {
2count.set(count.get() + 1)
3})
no-set-in-computed
.set() inside computed(). Computed values cannot have side effects.
Warning
setup.js
1function setup() {
2effect(() => render(count.get()))
3}
no-ignored-effect-return
Return value of effect() not captured. stop() will be unreachable.
DevTools panel
import 'kensington/devtools';
Kensington DevTools✕
ID
Value
Sets
DOM
Sub
#1
"idle"
×0
●
1
#2
42
×3
●
2
#3
["a","b","c"]
×1
○
1
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.
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:
Claude Code
: reference
node_modules/kensington/AGENTS.md
in your conversation, or add it to your project's
CLAUDE.md
.
Cursor / Windsurf
: add the file to your
.cursorrules
context or drag it into the chat.
Any chat interface
: paste the contents directly into the conversation before asking questions about Kensington.
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.
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.
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.
Attribute names:
checked against the HTML/SVG/MathML spec.
data-*
,
aria-*
,
additionalNamespaces
, and
additionalGlobalAttributes
are always allowed.
Attribute values:
checked against allowed types/literals (e.g.
type
on
input
only accepts known values;
id
must not start with a digit).
Style object values:
non-string/number values (other than
undefined
) are flagged.
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', {
'card-type': ['primary', 'secondary'], // allowed string literals
'loading': Boolean, // boolean attribute
'max-items': Number, // numeric attribute
'score': v => typeof v === 'number' && v <= 100, // custom validator function
});
}
const t = new MyEngine();
t.myCard({ 'card-type': '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:
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, the reconciler's insertBefore moves during drag-reorder
// trigger dom-tracker to permanently stop the item's signal effects (class, checked, etc.).
// With persist: true, effects pause on removal and resume when the node is re-inserted.
const item = t.li({ 'data-key': task.id, 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-->
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());
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.
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. In
.toString()
, all signal values are resolved to their current value inline. The
style
attribute also continues to accept a signal returning a whole object or string for cases where the entire style needs to change atomically.
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
Keyed lists
When a signal holds an array, add
dataKey
to items. The reconciler matches nodes by
data-key
and reuses DOM elements on reorder, addition, and removal. Reused nodes are diffed recursively: only changed attributes and text are written to the DOM. Signal-managed attributes on reused nodes are preserved, and orphaned effects on discarded nodes are stopped immediately.
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
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 { 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();
});
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.
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 coalesce into a single re-run via microtask batching.
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(
{
dataKey: item.id,
class: item.done.transform(d => d ? 'done' : 'open'),
},
item.label,
);
}
const list = t.ul(items.transform(arr => arr.map(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);
The keyed reconciler 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.
.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);
});
});
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()))
, 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({ 'aria-sort': sortAsc.transform(v => v ? 'ascending' : 'descending') });
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.
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. Signal effects are stopped first, then disconnected callbacks run. 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.
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.
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?)
Server
Renders the component to HTML and embeds state as a JSON script block. 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)
Client
Scans the page for components rendered by
renderForHydration
and mounts each one reactively. 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. Sets up a
MutationObserver
so components in dynamically fetched HTML fragments are hydrated automatically without re-calling
registerComponents
. Returns
{ stop() }
to disconnect the observer.
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.
Known tradeoffs
These are deliberate simplicity choices, not bugs.
DOM replacement, not true hydration.
The SSR elements are replaced with a fresh
toElement()
call rather than reusing them. In practice the swap is imperceptible. It is synchronous and the visual output is identical. Transitions are suppressed automatically on SSR elements until hydration completes.
Non-interactive window.
Elements are non-reactive between the browser's first paint and when the hydration script runs. This is inherent to SSR-then-hydrate.
State is plaintext.
State is embedded as a
<script type="application/json">
tag visible in page source. Do not pass secrets or tokens as hydration state.
Browser globals outside effect() will throw on the server.effect()
is suppressed during server-side rendering. For browser-only code that cannot go inside
effect()
: module-level code,
computed()
values, direct assignments. Use the
isBrowser
export:
isBrowser && localStorage.getItem('key')
.
One tag, one element.
Each tag instance maps to exactly one DOM node. Passing the same instance as a child of two different parents moves it rather than cloning it. Create separate tag instances if you need the same structure in two places.
Signal-driven .literal() does a full DOM replacement on each change.
The entire HTML subtree between the anchor comments is torn down and re-parsed on every signal update. There is no patching. Avoid using a frequently-changing signal with large
.literal()
content.
Reactive element reset after removal is asynchronous.
When a reactive element is removed from the DOM, its effects are stopped and the internal reference is cleared via MutationObserver. Calling
.toElement()
immediately after removal in synchronous code still returns the old element. Awaiting a tick (
await Promise.resolve()
) before the next
.toElement()
call ensures the reset has completed. Non-reactive elements are not affected:
.toElement()
returns the same node after removal and it can be re-inserted directly.
Module-level compute calls that are never subscribed to retain their source subscriptions indefinitely.
computed()
auto-disposes when its last subscriber unsubscribes, but a computed that never gains a subscriber never enters that cycle. Its internal update function stays subscribed to its source signals for the lifetime of the module. Call
.stop()
explicitly on such a computed when it is no longer needed.
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 }
).
Do not create signals or computeds inside a computed or transform callback
A
computed()
or
transform()
callback re-runs every time its dependencies change. Any
signal()
or
computed()
call inside the callback creates a brand-new instance on each re-run. These fresh instances never gain subscribers, so they go dormant immediately and leave behind orphaned entries in the devtools Signals panel. They also prevent the reconciler from reusing existing DOM nodes, because a new signal reference never matches the stored snapshot.
// Problem: a new computed is created on every re-render.
const rows = items.transform(list =>
list.map(item => {
const cls = computed(() => item.done ? 'done' : 'open'); // new instance each time
return t.li({ dataKey: item.id, class: cls }, item.label);
})
);
Create the reactive value once, when the item is first made, and store it on the item object.
// Fixed: the computed is created once per item, not once per render.
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.transform(list =>
list.map(item => t.li({ dataKey: item.id, class: item.cls }, item.label))
);
Use a named function for event handlers that read mutable state
Inline arrow functions in a
.map()
create a new reference on every render. The reconciler sees that the function changed, touches the DOM node to swap in the new handler, and rebuilds a snapshot. That is fine, but it means every re-render does extra work for each list item.
A named function defined outside the callback has a stable reference. The reconciler sees nothing changed and skips the node entirely. Because the function reads its closed-over variables at call time rather than capturing them, it always sees the current value.
// Inline arrow: new reference each render. Works correctly but the reconciler
// touches every node to swap in the updated handler.
let mode = 'view';
const rows = items.transform(list =>
list.map(item =>
t.li({ dataKey: item.id, onclick: () => handleClick(item.id, mode) }, item.label)
)
);
// Named function: stable reference. The reconciler skips unchanged nodes.
// mode is read at click time so it always reflects the current value.
let mode = 'view';
function handleClick(e) { doSomething(e.currentTarget.dataset.id, mode); }
const rows = items.transform(list =>
list.map(item =>
t.li({ dataKey: item.id, onclick: handleClick }, item.label)
)
);
mode = 'edit'; // all items see 'edit' when clicked, no re-render needed
Add data-key to list items that may change
Without a key, every re-render tears down all existing list nodes and builds fresh ones. With a key, the reconciler matches old nodes to new items by ID, reuses any node whose content is unchanged, and only touches the nodes that actually changed.
// Problem: all nodes are replaced on every update, even when most items are unchanged.
const rows = items.transform(list =>
list.map(item => t.li(item.label))
);
// Fixed: nodes are reused. Only added or removed items touch the DOM.
const rows = items.transform(list =>
list.map(item => t.li({ dataKey: item.id }, 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.
The import is safe in non-browser environments. It checks for
window
before mounting and does nothing on the server.
Panel
Kensington DevTools✕
ID
Value
Sets
DOM
Sub
#1
"idle"
×0
●
1
#2
42
×3
●
2
#3
["a","b","c"]
×1
○
1
The panel has five tabs.
Signals.
One row per
signal()
call. Shows the current value, set count, DOM visibility state, and subscriber count. Click a row to scroll the bound element into view. Click a value to edit it live. Hover a value to see the full JSON.
Computed.
Same view for
computed()
signals. Values are read-only. Shows sleeping state when a computed has no active subscribers.
Effects.
One row per
effect()
call. Shows state (active, paused), run count, dependency count, and the effect function source. Hover the function column to see the full source. Hover the dep count to see which signals the effect reads.
DOM.
One row per signal-to-DOM binding (a signal used in an attribute, content, or
prop
key). Shows the bound element, binding label, and run count. Hover a row to highlight the element on the page.
Log.
A timestamped feed of all signal, effect, and DOM binding events capped at 100 entries. Hover an event row to see the effect function source or the full signal value. A Copy button copies the currently visible entries as tab-separated text, respecting the active filter.
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.
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.
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)));
});
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.
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.
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.
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.
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.
A signal holds the todo array.
.transform()
derives a signal of rendered list items. Adding
dataKey
to each item lets the reconciler match nodes by key on re-render, so only changed items are written to the DOM.
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.
.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()
);
Incremental search
When the new query extends the previous one (e.g.
"cat"
to
"cats"
), existing results can be filtered client-side instantly with no spinner.
import { t, signal, computed, effect } from 'kensington';
const searchTerm = signal('');
const previousTerm = signal('');
const results = signal([]);
const isLoading = signal(false);
const status = computed(() =>
isLoading.get() ? 'Loading...' : `${results.get().length} result(s)`
);
effect(() => {
const current = searchTerm.get();
if (!current.trim()) return;
// previousTerm must be a signal -- it is shown reactively in the UI below.
// .value reads it without subscribing, so previousTerm.set(current) inside
// the fetch callback does not re-trigger this effect and fire a duplicate request.
const previous = previousTerm.value;
const isRefinement = previous.length > 0 && current.startsWith(previous);
if (!isRefinement) {
isLoading.set(true);
}
fetch(`/search?q=${encodeURIComponent(current)}`)
.then(r => r.json())
.then(data => {
results.set(data);
previousTerm.set(current);
isLoading.set(false);
});
});
document.body.append(
t.div([
t.input({
type: 'search',
placeholder: 'Search...',
oninput: e => searchTerm.set(e.target.value),
}),
t.p(status),
t.p(previousTerm.transform(p => p ? `Previous search: "${p}"` : '')),
t.ul(results.transform(items =>
items.map((item, i) => t.li({ dataKey: i }, item.title))
)),
]).toElement()
);
Sortable table
Two signals, sort column and sort direction, drive both the data rows and the column headers. Each header creates 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.
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.
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>
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.
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.
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.
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 => u.name === 'Guest'
? { name: 'Alice', role: 'admin' }
: { name: 'Guest', role: 'viewer' }
),
}, '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 };
}
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;
}
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 => l
? t.p('Loading...')
: error.get()
? t.p({ class: 'error' }, error.get())
: t.pre(JSON.stringify(data.get(), null, 2))
),
]).toElement()
);
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...',
hxGet: '/search',
hxTrigger: 'input changed delay:300ms',
hxTarget: '#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.
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.
Navigo
Navigo
is a small (~4 kb) client-side router with named routes, guards, and a
navigate()
helper. Wire its route callbacks into a signal and the rest of your UI reacts automatically.
Navigo intercepts link clicks itself when you use its
navigate()
method or annotate links with
data-navigo
, so the manual
click
delegation from the pushState example is not needed here.
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.
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 dom-tracker cleans up the effects 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.
// 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.
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.
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
h1
–
h6
,
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.
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.
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.
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', {
'card-type': ['primary', 'secondary'],
'loading': Boolean,
'max-items': 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
Signals are reactive values. Read them with
.get()
, write them with
.set()
, and derive new ones with
computed()
or
.transform()
. Pass a signal as an option value or content and
.toElement()
wires up live DOM updates automatically.
signal
import { signal } from 'kensington';
signal<T>(initialValue: T): Signal<T>
Creates a writable signal holding
initialValue
.
Signal methods
Method
Description
.get(): T
Returns the current value. When called inside
computed()
or
effect()
, registers this signal as a dependency of the running computation.
.value: T
Property getter. Returns the current value without tracking. Unlike
.get()
, reading
.value
inside
computed()
or
effect()
does not subscribe to this signal. The computation will not re-run when this signal changes.
.set(value: T | (prev: T) => T): void
Updates the value and notifies subscribers. Accepts a new value or an updater function. Throws if called on a signal created by
computed()
or
.transform()
.
.transform<U>(fn: (value: T) => U): Signal<U>
Returns a new read-only derived signal equivalent to
computed(() => fn(this.get()))
. Tracks all signals read inside
fn
, not just the source.
.stop(): void
Clears all subscribers. For signals created by
computed()
or
.transform()
, also tears down the derived computation and freezes the value.
.toJSON(): T
Returns the raw value without tracking side effects. Makes signals transparent to
JSON.stringify
.
.toString(): string
Calls
.get()
and converts to string. Allows signals to be used in template literals inside reactive contexts.
Creates a read-only signal whose value is derived from other signals. Re-evaluates
fn
synchronously whenever any signal read inside it changes. The returned signal exposes
.stop()
to unsubscribe from all tracked signals and freeze the value.
Runs
fn
immediately and re-runs it whenever any signal read inside it changes. Re-runs are deferred via
queueMicrotask
, so multiple synchronous
.set()
calls in the same turn batch into one re-run. Errors thrown inside the callback are re-surfaced asynchronously so they do not abort other pending effects.
const e = effect(() => {
document.title = `${count.get()} items`;
});
e.pause(); // unsubscribes temporarily
e.resume(); // re-runs fn and re-establishes subscriptions
e.stop(); // permanently destroys. resume() after stop() is a no-op
Elements created with
.toElement()
automatically stop their signal effects when removed from the DOM. During SSR (
renderForHydration
),
effect()
is a no-op.
prop key
Use the
prop
key to assign DOM properties directly (
el[name] = value
) instead of using
setAttribute
. This matters for properties that diverge from their HTML attributes after user interaction — notably
value
and
checked
on form elements — and for properties with no attribute equivalent such as
muted
and
playbackRate
on media elements.
const query = signal('');
// Assigns el.value reactively — keeps the live property in sync
t.input({ type: 'search', prop: { value: query } }).toElement();
// Static prop — assigned once at render time
t.video({ src: '/intro.mp4', prop: { muted: true, playbackRate: 1.5 } }).toElement();
Accepts a plain object whose values are static or
ReadonlySignal
. Silently ignored in
.toString()
. Known writable properties (those on the element's DOM interface) are typed in TypeScript. Expando properties and arbitrary string keys are also accepted. Property existence and writability are validated at render time against the live element and reported via
validationLevel
.
Renders a component to an HTML string for server-side delivery, then embeds the state as a
<script type="application/json">
block so the browser can replace it with a live reactive DOM. Signal effects are suppressed during the component call. The component function must be synchronous.
name
defaults to
fn.name
when called server-side. Pass an explicit string when calling in the browser. Bundlers and minifiers rename function identifiers, so
fn.name
is not reliable after a production build. Passing an explicit name is also required for anonymous functions. The same name is used by
registerComponents
to match script blocks to component functions on the client.
State must be a plain serializable object. Values that cannot survive
JSON.stringify
(functions, symbols, BigInt, circular references, class instances) cause a warning or throw.
// server
res.send(layout(renderForHydration(counter, { count: 0 })).toString());
Registers component functions and hydrates all server-rendered instances already in the page. Each matching
<script type="application/json" data-k-component="…">
block is replaced with the live reactive DOM produced by the component function. A
MutationObserver
is installed to handle components inserted dynamically after this call.
Returns
{ stop() }
to disconnect the observer and halt auto-hydration.
// client
const { stop } = registerComponents({ counter, userCard });
// later, if you want to stop watching for new components:
stop();
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';
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();
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:
Hunting a bug in the reactive system
Adding a new tag-class variant or rendering mode
Designing an integration that needs to understand cleanup semantics
Curious how a small library supports signals, SSR, hydration, and reconciliation in roughly 1 200 lines of hand-written source
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()
.
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
.
Generated file
esm/kensington.js
is generated by
write-code-files.js
from spec data. Do not edit it directly. All hand-written source lives in
and
.
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.
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;
}
Key behaviors
false
and
true
are dropped. This is what makes
condition && t.span(...)
work.
null
,
undefined
, and empty string are dropped.
Arrays flatten recursively. A cycle-detection Set prevents infinite recursion on accidentally circular content.
Signals pass through unchanged and are resolved at render time.
Validation
If validationLevel is 'warn' or 'error', the tag runs
validate()
immediately after construction (see
validate.js
):
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
.
Report them via showInvalid.
At 'warn' this logs; at 'error' this throws.
Collect invalid attribute values.
For each allowed attribute, run
attributeValueIsValid
against the type spec.
Report invalid values
as a single combined message so the developer sees all problems at once.
Never throws at 'off'
All validation goes through
show-invalid.js
. At 'off' it's a no-op. Production deployments run with 'off' for performance. A malformed attribute in user data must not crash the page.
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
:
Filter invalid content
via
validateContent()
. Items that aren't a string, finite number, tag instance, or Signal are dropped and reported via showInvalid.
Open the tag.
Concatenate
'<'
, the tag name, the attribute string, and
'>'
.
Render the content body
via one of three paths (below).
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 (content instanceof Signal) { 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"
with HTML encoding. Booleans render as the bare attribute name (
disabled
not
disabled="true"
). Function values cannot be serialized to strings and are silently omitted.
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.
flowchart TD
S(["toElement()"]) --> A{"domElement cached?"}
A -- yes --> R1["return cached"]
A -- no --> B["validateContent"]
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 --> R2["return element"]
Cache check
If
#domElement
is set, return it immediately. If the cached element is already in the DOM (
parentNode !== null
), this would silently move the node.
showInvalid
reports it.
if (this.#domElement) {
if (this.#domElement.parentNode !== null) {
showInvalid('toElement() called on a tag instance already in the DOM ...', ...);
}
return this.#domElement;
}
Why cache?
So that
getDomElement()
returns a stable reference, and so that re-calling
toElement()
on the same instance does not produce two independent DOM nodes that fight over the same signals.
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:
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:
The two comment nodes are held only by the effect's closure.
markContentTracked(element)
tells the reconciler to never replace this element's children, even if a parent reconcile sees a fresh element with different children.
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:
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 cleanup function is pushed to the effect's
_cleanups
array, which the
track
helper drains and resets on each re-run.
.value and .toJSON() never subscribe
.value
(getter) and
.toJSON()
both return
this.#value
directly. Reading
.value
inside an effect does not create a dependency.
.toString()
calls
.get()
, so template literals inside reactive contexts do track.
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
.
Error isolation in batches
flush()
wraps each effect run in try/catch and re-throws via
queueMicrotask
. One effect's thrown error does not abort the batch. Every queued effect still runs.
Loop guards
flush()
tracks re-queue counts per effect via a runCounts Map. After
MAX_EFFECT_LOOPS = 100
re-queues for the same effect in one flush pass, it fires
console.error
and stops re-running that effect. A separate
flushCount
counter catches async flush loops: after
MAX_FLUSHES = 500
consecutive flushes, it fires
console.error
and clears the pending set.
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) {
if (ssrDepth > 0) {
return { pause() {}, resume() {}, stop() {} };
}
let paused = false;
let destroyed = false;
function run() {
if (paused) { return; }
track(run, fn);
}
run._cleanups = [];
run._isEffect = true;
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 the internal
createEffect
path but skips the effect-in-effect and effect-in-computed warning checks. The lifecycle module uses it because it legitimately creates effects inside running effects during reconcile, and those effects are correctly managed by dom-tracker.
computed()
computed(fn)
at
signal.js
creates a Signal whose value is derived from other signals. Updates are synchronous (unlike effects).
Under
ssrDepth > 0
,
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.
computed inside effect
signal()
called inside a
computed
or
effect
callback emits a throttled error via filterStack (see
filter-stack.js
). A new signal is created on every re-run, breaking the reconciler's snapshot fast path and leaving orphaned sleeping signals.
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 stops = []; // pause-or-stop closures, one per signal effect
const devIds = []; // effect IDs for devtools
const resumables = persist ? [] : null; // effect objects for resume() on reconnect
const elementRef = new WeakRef(element); // shared across every signalEffect
function pauseOrStop(eff) {
return () => persist ? eff.pause() : eff.stop();
}
function wireEffect(eff) {
stops.push(pauseOrStop(eff));
if (resumables !== null) { resumables.push(eff); }
}
}
resumables
is allocated only when
persist
is true. The common non-persist case has zero overhead from it.
signalEffect
signalEffect(sig, apply, label) {
markNextEffectAsBinding(label); // devtools: categorise as DOM binding with this label
const eff = _internalEffect(() => {
const el = elementRef.deref();
if (!el) { eff.stop(); return; } // element collected; self-stop
apply(el, sig.get());
});
notifyEffectElement(eff._devId, element); // devtools: link effect to element
wireEffect(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.
_internalEffect
is used here instead of
effect
because lifecycle.js legitimately creates effects while other effects are running (during reconcile). The effect-in-effect guard in the public
effect()
export would fire spuriously.
WeakRef is the GC safety net
If a user creates a tag, calls
toElement
, and then drops every reference without ever inserting the element, the element is eligible for GC. Without WeakRef, the Signal's subscriber set would hold the effect closure, which would hold the element by reference. WeakRef breaks that cycle.
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, () => { for (const stop of stops) { stop(); } }, devIds);
if (onCleared) { addOnStop(element, onCleared); }
for (const fn of disconnectCallbacks) {
addOnStop(element, () => fn.call(element, element));
}
}
trackForStop
registers the first link: running every signal effect's pauseOrStop closure.
addOnStop
appends to that 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 firstConnection = true;
trackForConnect(element, () => {
if (!firstConnection) {
if (onReconnect) { onReconnect(); }
if (resumables !== null && resumables.length > 0) {
for (const eff of resumables) {
eff.resume();
addOnStop(element, () => eff.pause());
}
}
}
firstConnection = false;
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 is gated on !firstConnection.
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 trackedRefs = new Set();
const trackedCleanup = new FinalizationRegistry(ref => trackedRefs.delete(ref));
const contentTracked = new WeakSet();
entries
is a WeakMap keyed by element. Each entry holds a stop function, an optional connect function, and a persist flag. A parallel
trackedRefs
Set (of WeakRefs) supports the iteration in
visit()
. The FinalizationRegistry removes dead WeakRefs as elements are collected so the
trackedRefs.size
short-circuit stays approximately accurate.
Why WeakMap + a parallel ref set?
A plain Map would pin every tracked element by key, so an element produced by
toElement()
and then dropped without ever being inserted would never be collected. The WeakMap avoids that.
trackedRefs
provides the iterable needed by
visit()
without pinning elements.
The observer
function buildObserver() {
if (observer !== null) { return; }
observer = new MutationObserver(records => {
if (trackedRefs.size === 0) { return; } // skip when nothing is tracked
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
trackedRefs.size === 0
short-circuit means mutation records are skipped without any per-record work when nothing is 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:
function visit(node, fn) {
const own = entries.get(node);
if (own !== undefined) {
fn(node, own);
// Don't return — also process tracked child elements so that effects on
// descendants are paused or stopped together with the parent.
}
if (node.nodeType !== 1) { return; }
for (const ref of [...trackedRefs]) { // snapshot to avoid mutation during iteration
const el = ref.deref();
if (el === undefined) { trackedRefs.delete(ref); continue; }
if (el === node) { continue; }
if (own !== undefined && el.nodeType !== 1) { continue; } // skip comment anchors on persist parents
if (node.contains(el)) {
const entry = entries.get(el);
if (entry !== undefined) { fn(el, entry); }
}
}
}
visit() does not return early
Even when the node itself has an entry,
visit()
continues to check
trackedRefs
for child elements. This ensures that effects on descendants (for example, a
checked=signal
attribute on an
<input>
inside a persist-mode parent
<li>
) are paused or stopped together with the parent.
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.
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
Browser fires the MutationRecord.
removedNodes contains the directly-removed node. The tracked element may be that node or a descendant.
stopRemoved(node).
Calls visit(node, fn) which finds the tracked entry for the node or any tracked descendant.
clearStop(entry, el).
Deletes entry.stop. If not persisted, also deletes connect and persist. If both halves are gone, removes the entry entirely.
The captured stop function runs.
This is the chained closure built via trackForStop and every addOnStop.
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.
onCleared runs.
Resets the tag's #domElement cache to null.
Each user disconnect callback runs.
In registration order.
Removal vs stopTracked()
The removal path above is triggered automatically by the MutationObserver.
stopTracked(el)
at
dom-tracker.js
does the same teardown synchronously without waiting for a mutation record. The reconciler calls this on discarded fresh nodes before returning to the caller.
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
Pause, don't stop.
Every signal effect is captured in resumables. On removal, pauseOrStop picks pause(). The effect closure still exists, just unsubscribed.
Disconnect callbacks re-arm.
reFireAndRegister installs a fresh stop chain after each removal so the next removal fires them again.
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.
Resume wires its own pause.
Right after eff.resume(), the lifecycle adds () => eff.pause() to the new stop chain. The cycle continues.
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
When NOT to use persist
If connectedCallback creates a fresh element each time (the common Web Components pattern), persist is wrong. The old paused effects become orphaned when the new element replaces them. Use
persist: true
only when you hold the same DOM node across reconnections.
Every signal-content update calls
reconcile
at
reconcile.js
. The function patches the DOM in place rather than tearing it down and rebuilding. It handles both single-value and array-valued signals. Non-arrays are wrapped as
[val]
before passing in, so the algorithm only handles the array case.
Reconciliation runs between a pair of comment anchors set up at element construction. The anchors give the function stable boundaries:
startAnchor.nextSibling
is the first child to consider,
endAnchor
is the sentinel.
Keyed matching
Each item can have a
data-key
attribute. Keyed items match against existing children with the same key, not the same positional index. This enables efficient reordering:
export function reconcile(parent, startAnchor, endAnchor, newItems) {
const oldNodes = new Map();
let node = startAnchor.nextSibling;
while (node !== endAnchor) {
const key = node.dataset?.key;
if (key !== undefined) { oldNodes.set(key, node); }
node = node.nextSibling;
}
// ...
}
Without a key, items are matched positionally and recreated if the shape differs.
Snapshot fast path
Once a keyed match is found, the reconciler checks a structural snapshot of the previous render. A WeakMap keyed by DOM node holds the last (attributes, content) pair that produced it. If the new tag's attributes and content are value-equal to the snapshot, the entire
itemToNode(item)
and
syncNode
chain is skipped. The existing DOM node is reused unchanged.
valueEqual
compares plain objects and arrays structurally, recurses into ContentTag instances (matching on tagName + attributes + content), and falls back to reference equality for everything else (functions, Signal, LiteralTag, CommentTag, DOM nodes, Date, class instances).
Why value equality, not reference equality
The natural pattern
arr.map(item => t.li({ class: item.cls }, item.label))
allocates a fresh attribute object literal on every render. Reference equality on those literals would always miss. Value equality detects the structurally identical literal and skips the rebuild without requiring the developer to memoize.
A stable Signal reference hits the fast path via reference equality. A fresh closure or fresh LiteralTag on each render does not. The snapshot is recorded only on the non-fast-path branch, so an item that keeps hitting the fast path retains its original snapshot indefinitely.
Circular import
reconcile.js
imports ContentTag for the
instanceof
check in
valueEqual
, and
content-tag.js
imports reconcile for its signal-content effect. Both sides use the other inside function bodies at call time, not at module-load time, so ESM live bindings resolve correctly. Rollup emits a CIRCULAR_DEPENDENCY warning that is informational only.
syncNode
syncNode(existing, fresh)
handles a matched pair. If the node types differ, the fresh node replaces the existing one. If they're both text nodes, only
nodeValue
is patched. If they're both elements, it applies fresh attributes and recursively syncs child pairs.
The guards
Why guards are needed
The fresh node passed to syncNode is the result of calling
itemToNode(item)
, which calls
item.toElement()
. That fresh node is fully wired with its own signal effects pointing at the fresh element. Patching attributes or children naively would corrupt the live element's effects.
// Attribute guard
if (!isTracked(existing)) {
for (const attr of oldAttrNames) {
existing.removeAttribute(attr);
}
}
// Content guard
if (!isContentTracked(existing)) {
// positional sync of child nodes
}
isTracked(existing).
If true, the existing element has signal-managed attributes. Don't remove attributes that weren't on the fresh node. The signal effects haven't yet applied their initial values to the fresh element when reconcile inspects it.
isContentTracked(existing).
If true, the existing element holds signal-content comment anchors. Don't patch its children at all. Replacing the anchors would break the live content effects whose closures still reference them.
After patching,
stopTracked(fresh)
tears down the discarded fresh node's effects. This is called synchronously (not waiting for the MutationObserver) to close the window where a just-removed node could still respond to signal changes.
Insertion, reuse, leftover cleanup
The main reconcile loop walks newItems in order:
null
,
undefined
, and
false
items are skipped.
If the item has a key and matches an existing keyed node, call syncNode. The result is either the patched existing node or the fresh node if types diverged.
If no match (or no key), build a new node via itemToNode.
If
cursor === targetNode
, advance. The node is already in position. Otherwise call
parent.insertBefore(targetNode, cursor)
to slide it into place.
After the loop, every node between cursor and endAnchor is leftover. Remove them and call
stopRemoved
synchronously to stop their signal effects.
Every entry remaining in the oldNodes map is a keyed node whose key was not in newItems. Remove and stop them too.
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 the
ssrDepth
counter at
signal.js
.
The SSR bypass
export function effect(fn) {
if (ssrDepth > 0) {
return { pause() {}, resume() {}, stop() {} }; // no-op stub
}
// ... normal path ...
}
export function computed(fn) {
if (ssrDepth > 0) {
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 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.
Why computed() needs the bypass too
Without it, a per-request
computed
or
transform
reading a module-level signal would register an update closure in that signal's subscriber set. The signal lives across requests, so each render leaves one dead subscriber behind. Over time the subscriber set, and the time spent iterating it on every
.set()
, grows without bound.
renderForHydration
renderForHydration(fn, state, name)
in
hydration.js
wraps a component for isomorphic rendering. On the server, it increments
ssrDepth
, invokes
fn(state)
to produce a tag instance, calls
toString()
, embeds the resulting HTML alongside a JSON state block, and decrements
ssrDepth
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.
registerComponents + the JSON block
On the client,
registerComponents({ name: fn })
reads the JSON block embedded by the server render, looks up each registered component by name, and runs
fn(state)
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.
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.
Why not reuse?
Reuse hydration requires the SSR HTML and the client's tag tree to match exactly. The current strategy avoids that constraint at the cost of one extra DOM swap per component.
Invariants
The rules that hold across every code path. Violations are bugs.
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.
Signal values are accepted everywhere a plain value is accepted.attributeValueIsValid
returns true for Signals without inspecting them. Resolution happens at render time.
.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.
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.
The reconcile guards must never be bypassed.
isTracked prevents attribute-strip on signal-managed elements. isContentTracked prevents child-patch on elements holding signal-content anchors. stopTracked is called synchronously after every .remove() in the reconciler so effects stop before the MutationObserver fires.
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.
The DOM tracker has exactly one observer for the whole document.
Built lazily on the first trackForStop or trackForConnect call.
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.
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.
_internalEffect is for library-internal use only.
It skips the effect-in-effect and effect-in-computed guard checks. Only lifecycle.js should call it.
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
Almost every browser test in
signals.spec.js
exercises one of the paths above. Running
npm run test-browser
after any change to signal.js, lifecycle.js, dom-tracker.js, or reconcile.js is the fastest way to catch regressions.