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.
Why Kensington?
We wanted flying cars
, instead we got 140 thousand hours of React tutorials.
We find ourselves spending more time trying to parse framework docs,
and less time actually writing code.
Kensington hopes to alleviate some of these ails by presenting a simpler alternative to the frameworks.
The basics can be learned in a few minutes, and the entire API can be learned in half an hour.
Kensington handles the structural work automatically, leaving a simple api for a dev to parse.
There are no magic attributes to memorize, and no new HTML templating language to learn.
It is plain JavaScript function and method calls.
If you can read the code, you can guess what it does, even before reading the docs.
Comprehensive typing, lint rules, IDE plugins, and server integrations help keep your code clean.
In case you let robots write your code, Kensington is very AI-friendly,
and produces code that is simple enough to be reviewable by a human.
Building HTML
Elements & content
Every HTML, SVG, and MathML element is available as a method on
t
. All call forms work:
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+.
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', {
cardType: ['primary', 'secondary'], // allowed string literals
loading: Boolean, // boolean attribute
maxItems: Number, // numeric attribute
score: v => typeof v === 'number' && v <= 100, // custom validator function
});
}
const t = new MyEngine();
t.myCard({ cardType: 'primary' }, t.p('content')).toString();
// → <my-card card-type="primary">
// <p>content</p>
// </my-card>
To extend a built-in element with extra attributes, import its attribute object from
kensington/attributes
and spread it into
createCustomTag
:
import Kensington from 'kensington';
import { buttonAttributes } from 'kensington/attributes';
class MyEngine extends Kensington {
button = this.createCustomTag('button', {
...buttonAttributes,
popovertarget: String, // add an attribute not yet in the spec data
});
}
const t = new MyEngine({ validationLevel: 'error' });
t.button({ type: 'button', popovertarget: 'my-popover' }, 'Open').toString();
Every element in the spec has a corresponding named export (
divAttributes
,
inputAttributes
, …) available from
kensington/attributes
.
Use
ContentMethod<T>
to type a custom element method, and module augmentation to allow custom attribute namespaces without a subclass:
By default,
.toElement()
stops signal effects permanently when an element is removed from the DOM. For elements that will be moved or temporarily removed and re-inserted, add
persist: true
to the tag options. Effects are paused on removal and resume automatically on re-insertion, across any number of cycles.
// Without persist: true, removing the item during a drag-reorder permanently stops
// its signal effects (class, checked, etc.).
// With persist: true, effects pause on removal and resume when the node is re-inserted.
const item = t.li({ class: statusClass, persist: true }, [
t.input({ type: 'checkbox', checked: task.done }),
t.span(task.text),
]);
persist: true
is silently ignored in
.toString()
and has no effect on server-side rendering. It only changes behavior when an element created by
.toElement()
is removed and re-inserted into the DOM.
Raw HTML & comments
t.literal('<li>verbatim, HTML-encoded</li>'); // <script> tags flagged via validationLevel
t.unsafeLiteral('<li>trusted HTML, no encoding</li>');
t.inlineComment('hello world'); // <!-- hello world -->
t.inlineComment('line 1\nline 2'); // <!--\n line 1\n line 2\n-->
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.
Two reactive shapes are supported. Use whichever fits the data flow.
Per-property signals
Individual properties inside a
style
object accept signals. Only the changed property is written to the DOM on each update. All other properties are left untouched.
const color = signal('red');
const opacity = signal(1);
t.div({
style: {
color, // reactive. Only color is updated when the signal changes
opacity, // reactive. Only opacity is updated when the signal changes
fontSize: '1rem', // static. Set once at render time
},
}).toElement();
color.set('blue'); // writes el.style.setProperty('color', 'blue')
opacity.set(0.5); // writes el.style.setProperty('opacity', '0.5')
A signal that resolves to
null
,
undefined
,
false
, or
''
calls
removeProperty
on that property.
Whole-style signals
The
style
slot also accepts a signal that yields the entire style object. Each emission is diffed per-property against the previous; properties that changed are written, properties that disappeared from the new object are cleared via
removeProperty
. Use this when one derived signal naturally produces the whole bundle, such as a computed position.
const pointer = signal({ x: 0, y: 0 });
const position = computed(() => ({
position: 'absolute',
top: `${pointer.get().y}px`,
left: `${pointer.get().x}px`,
}), 'position');
t.div({ id: 'cursor', style: position }).toElement();
// pointer.set({ x: 40, y: 80 }) writes top + left in one emission;
// properties removed from a later emission are cleared from el.style.
The same shape works at any depth inside
data
,
aria
, and any other namespaced-attribute slot.
data: signal({foo: 'bar'})
flattens to
data-foo="bar"
, and
data: { bs: signal({toggle: 'collapse'}) }
flattens to
data-bs-toggle="collapse"
.
prop
and
on
do not support whole-object signals; use per-property signals there.
In
.toString()
, all signal values are resolved to their current value inline.
DOM properties
Sets a property instead of an attribute.
input.value
reflects what the user typed, while
getAttribute('value')
still returns the original default. Use the
prop
key to assign directly to DOM properties via
el[name] = value
, bypassing
setAttribute
:
const userInput = signal('');
// Assigns el.value = '' reactively, keeping the live property in sync
t.input({ type: 'text', prop: { value: userInput } }).toElement();
// Resetting
userInput.set(''); // el.value resets immediately
// Properties with no HTML attribute equivalent
const isMuted = signal(true);
t.video({ src: '/intro.mp4', prop: { muted: isMuted, playbackRate: 1.5 } }).toElement();
isMuted.set(false); // unmutes video
With .literal and .inlineComment
const html = signal('<b>bold</b>');
t.div(t.literal(html)).toElement();
// element is replaced when html changes
const note = signal('draft');
t.div([t.p('content'), t.inlineComment(note)]).toElement();
// comment nodeValue updates live
Live signals
A live signal is a signal whose value is shared by name across every connected browser
and
the server. Reads from any tab reflect writes from any other tab. Reads on the server see the same value. The API is the same as
signal()
.
A shared file declares the signal once and exports it. Both the SSR component and the server-side observer use the same exported instance.
// shared/viewers.js
import { t } from 'kensington';
import { liveSignal } from 'kensington/live';
export const viewerCount = liveSignal(0, 'home:viewers');
export function viewers() {
return t.div([
t.span([viewerCount, ' people viewing. ']),
t.button({ onclick: () => viewerCount.set(n => n + 1) }, 'I am here'),
]);
}
The server wires up the live transport, serves the SSR markup for the component, and can read the same signal to react to client writes.
// server.js
import http from 'node:http';
import express from 'express';
import { effect, renderForHydration } from 'kensington';
import { liveServer } from 'kensington/live';
import { viewerCount, viewers } from './shared/viewers.js';
const app = express();
app.use(express.static('public')); // serves /client.js and other assets
app.get('/', (req, res) => {
res.type('html').send(`<!doctype html>
<script type="module" src="/client.js"></script>
${renderForHydration(viewers, {})}`);
});
const httpServer = http.createServer(app);
const live = await liveServer();
await live.attach(httpServer);
// The server sees the same value. Each client write fires this effect.
effect(() => console.log('viewers:', viewerCount.get()));
httpServer.listen(3000);
The client opens the WebSocket and hydrates the shared component.
// client.js
import { registerComponents } from 'kensington';
import { connectLive } from 'kensington/live';
import { viewers } from './shared/viewers.js';
connectLive();
registerComponents({ viewers });
Persistence, permissions, and writes that need to converge across concurrent clients are covered in
live signals
under Advanced.
Advanced Usage
The above usage may be enough for many projects,
but if you are building a more complex app, you may need these tools.
.value
Use
.value
instead of
.get()
inside
effect()
or
computed()
when you need the current value of a signal without subscribing to changes:
const searchTerm = signal('');
const previousTerm = signal('');
// Re-runs when searchTerm changes. previousTerm.value reads without subscribing.
// Using .get() would subscribe the effect to previousTerm, and the .set()
// in the callback would re-trigger the effect, firing a duplicate request.
effect(() => {
const current = searchTerm.get();
const previous = previousTerm.value;
const isRefinement = current.startsWith(previous) && previous.length > 0;
fetch(`/search?q=${current}`)
.then(r => r.json())
.then(data => {
results.set(data);
previousTerm.set(current);
});
});
Returns a new read-only signal whose value is derived by passing the source signal's value through a function. Equivalent to
computed(() => fn(source.get()), key)
, but attached directly to the signal.
const count = signal(0);
const label = count.transform(n => n === 1 ? '1 item' : `${n} items`);
t.p(label).toElement(); // "0 items", updates when count changes
// useful for coercing a signal's type before passing it as an attribute
const sortAsc = signal(true);
t.th({ ariaSort: sortAsc.transform(v => v ? 'ascending' : 'descending') });
Inside a
computed
callback, pass an optional stable
key
as the second argument to scope the transform to the owning computed. Same lifecycle as
computed(fn, key)
: the same instance is reused across outer re-runs, the fn closure is updated automatically, and the instance is stopped when its key leaves the list.
When a signal holds an array, the most direct way to render it is to
.transform
the signal and use a plain
array.map
. This works. The library will pick up every change and the UI stays in sync.
const items = signal([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
]);
// Plain map. Correct, but every render builds fresh tag instances and fresh DOM
// for each one. Adding a row rebuilds every existing <li>.
t.ul(items.transform(arr => arr.map(item => t.li(item.name)))).toElement();
The catch is performance.
arr.map(item => t.li(...))
produces a fresh tag for every item on every re-render, so Kensington cannot tell that the new
<li>
at position 0 is the same Apple as before and builds a fresh DOM node. For a 10-row list this is invisible. For 1000 rows with frequent updates the cost adds up. Focus, scroll, and input value also reset because the DOM nodes are new each time.
signal.mapWithKey(keyOrProp, mapFn)
is the optimized form. It runs
mapFn
once per key the first time the key is seen and caches the resulting tag. Subsequent renders return the same tag instance, so Kensington reuses the existing DOM node. Reordering, adding, and removing items reorder existing nodes rather than rebuilding them.
Function form. item => key
. Use when the key isn't a single property on the item, or when you need to compose it (
item => item.group + '-' + item.id
).
Property-name string. 'id'
. Common case. Equivalent to
item => item.id
.
mapWithKey
returns a
ReadonlySignal<Tag[]>
. Pass it directly into tag content. Calling
mapWithKey
inside a
computed
or
effect
callback logs a warning because the per-key cache would reset on every outer re-run. Call it at the same scope where you call
signal()
.
Duplicate keys in the same render fire a
console.error
and the first item wins. The duplicate is silently skipped, so each unique key always corresponds to exactly one cached tag.
Inside
mapWithKey
's mapFn, the same keying rules that apply inside any
computed
callback apply here. Pass the item id as the key to
signal()
,
computed()
, or
.transform()
so each per-item instance is scoped to the row and stopped automatically when the item leaves the list.
Because mapFn only runs the first time a key is seen, these per-item primitives are created once and live for the life of the row. Removing the item drops it from the list and its primitives are stopped automatically. See the
editable rows example
→
for a realistic use of these patterns together.
Don't reference a keyed instance from outside its scope
A keyed primitive is stopped whenever its key isn't accessed during a re-run (e.g. when the item leaves the list). After that point, external subscribers held in user-land code silently stop receiving updates. Use the instance freely inside the mapFn (read it with
.get()
, transform it, pass it as tag content or an attribute value, etc.), but don't let the instance reference itself escape. The unsafe patterns are assigning it to a module-level variable, returning it bare from the mapFn, or passing it to a function that retains it.
The library emits a runtime warning, and the
no-out-of-scope-reactive-reference
ESLint rule catches it statically, when a keyed instance is referenced from outside its owner.
When you need a key
Always pass a key to a nested
signal()
. An unkeyed signal inside a computed loses its local state on every outer re-run.
For
computed()
and
.transform()
, pass a key when the result is read from user code (a
signal()
handler, another
computed()
, or an
effect()
). Skip the key when the result is only passed to a tag as an attribute value, class entry, text child, or prop. Those inline uses are handled by the runtime and don't need a key.
When updates fire
A signal notifies its subscribers when
.set()
is called with a value that differs from the current one. The check is reference-style (
Object.is
), not deep.
Value type
What "differs" means
string
,
number
,
boolean
,
null
,
undefined
Different value.
signal.set(3)
when the current value is
3
is a no-op.
Array
,
Object
, anything else
Different reference. Mutating the existing value in place does not count. You must produce a new array or object.
This is the most common source of "my signal isn't updating" confusion. The fix is to update immutably.
Immutable update patterns
The same shapes work for any reactive library and all have built-in support in modern JavaScript.
// Array: replace one item by id, keep the others
items.set(prev => prev.map(it => it.id === 5 ? { ...it, done: true } : it));
// Array: add an item
items.set(prev => [...prev, newItem]);
// Array: remove an item
items.set(prev => prev.filter(it => it.id !== 5));
// Object: change one field
user.set(prev => ({ ...prev, name: 'Ada' }));
// Nested object: change a deep field (each level needs a spread)
state.set(prev => ({
...prev,
profile: { ...prev.profile, name: 'Ada' },
}));
If a field needs to update frequently and is deeply nested, give it its own signal rather than reaching for spreads on every level. See
Per-row signals
below.
What does
not
trigger an update
const items = signal([{ id: 1, label: 'a' }, { id: 2, label: 'b' }]);
// 1. Mutating an element of the array. No update.
items.get()[0].label = 'changed';
// 2. Setting the signal to the same array reference. No update.
items.set(items.get());
// 3. Mutating then re-setting with the same reference. Still no update.
items.get()[0].label = 'changed';
items.set(items.get());
// 4. In-place array methods like push, pop, shift, unshift, splice, sort, reverse.
// All mutate the existing array. The signal isn't notified.
items.get().push({ id: 3, label: 'c' });
items.get().splice(0, 1); // remove the first item
items.get().sort((a, b) => a.label.localeCompare(b.label));
// 5. Object.assign on an existing object. The returned value is the same target reference,
// so even re-setting after it does nothing.
Object.assign(items.get()[0], { label: 'changed', done: true });
// 5a. Capturing the array first, mutating, then re-setting doesn't help either. `arr` is
// the same reference as items.get(), so signal.set short-circuits via Object.is. The
// value returned by Object.assign is also the same target reference.
const arr = items.get();
Object.assign(arr[0], { label: 'changed', done: true });
items.set(arr); // no update
All five patterns leave the DOM stale. The first, fourth, and fifth update internal state but never tell the signal anything happened. The second, third, and 5a get short-circuited because
Object.is(items.get(), items.get())
is true regardless of whether the value was mutated in between.
Mutating helpers like
splice
,
sort
, and
Object.assign
are particularly easy to reach for because they look like they "update" the value. They do, but the signal doesn't know. The non-mutating forms work as expected:
// Remove an item: filter to a new array
items.set(prev => prev.filter((_, i) => i !== 0));
// Add an item: spread into a new array
items.set(prev => [...prev, { id: 3, label: 'c' }]);
// Sort: toSorted returns a new array (ES2023+, or use [...prev].sort())
items.set(prev => prev.toSorted((a, b) => a.label.localeCompare(b.label)));
// Patch fields on an item: spread the item into a new object
items.set(prev => prev.map(it =>
it.id === 1 ? { ...it, label: 'changed', done: true } : it,
));
When the DOM actually updates
Once a signal fires, what happens to the DOM depends on where the signal is used.
Use site
What updates
t.div(signal)
(signal as content)
The text node (or the matching set of child nodes for an array signal) is patched in place. Surrounding content is untouched.
t.input({ value: signal })
(signal as attribute)
Just that attribute.
setAttribute
is called. Boolean attributes are added or removed.
t.input({ prop: { value: signal } })
(signal as DOM property)
Just that property.
element[prop] = value
is called. Required for things like
input.value
after the user has typed into the field.
effect(() => ...)
inside
The effect re-runs. Multiple
.set()
calls in the same synchronous turn are batched into a single re-run.
computed(() => ...)
inside
The computed re-evaluates synchronously. Its subscribers then update as above.
Per-row signals for fine-grained updates
For lists where individual rows change often, store a signal on each item
rather than reactively re-rendering the entire array.
// The whole `items` array doesn't need to re-render when one row's done flag flips.
const items = signal([
{ id: 1, label: 'Buy milk', done: signal(false) },
{ id: 2, label: 'Walk dog', done: signal(true) },
]);
function row(item) {
return t.li(
{ class: item.done.transform(d => d ? 'done' : 'open') },
item.label,
);
}
const list = t.ul(items.mapWithKey('id', row)).toElement();
// Update one row. The parent `items` signal does not fire. Only the affected element's
// class attribute is rewritten. Adding or removing a row still uses items.set() with a
// fresh array.
items.get()[0].done.set(true);
mapWithKey is built for the array-set path (adding, removing, reordering rows). Per-row signals are the right tool when only a row's contents change.
Existing elements
When most of a page is static HTML, it is simpler to reach into the DOM with
querySelector
and drive updates with
effect()
directly rather than rebuilding large chunks of markup with
.toElement()
.
import { t, signal, effect } from 'kensington';
const theme = signal('light');
// Toggle a class on a single element
const root = document.documentElement;
effect(() => {
root.classList.toggle('dark', theme.get() === 'dark');
});
// Drive a set of elements from one signal
const currentTab = signal('overview');
document.querySelectorAll('[data-tab-content]').forEach(el => {
effect(() => {
el.classList.toggle('hidden', el.dataset.tabContent !== currentTab.get());
});
});
// Update text content
const count = signal(0);
const label = document.getElementById('count-label');
effect(() => {
label.textContent = count.get();
});
Cleanup
Elements created with
.toElement()
automatically stop their reactive effects when the element is removed from the DOM, whether by
el.remove()
or by removing an ancestor.
To pause effects instead of stopping them, add
persist: true
to the tag options. Effects resume automatically when the element is re-inserted, and pause again if it is removed a second time. This works across unlimited cycles.
const cls = signal('idle');
const el = t.div({ class: cls, persist: true }).toElement();
document.body.append(el);
el.remove(); // effects pause. cls.set() has no DOM effect
document.body.append(el); // effects resume
cls.set('active'); // DOM updates immediately
For effects that run outside of any element, call
e.pause()
to temporarily unsubscribe and
e.resume()
to restart. Call
e.stop()
when the effect is no longer needed. It permanently destroys it and
resume()
becomes a no-op. To tie an effect to a component's lifetime without manual bookkeeping, use
addDisconnectedCallback
. See
Lifecycle
below.
Lifecycle
Kensington tag objects support lifecycle callbacks via
addConnectedCallback(fn)
and
addDisconnectedCallback(fn)
, mirroring the web component lifecycle. Call them on a tag object before calling
.toElement()
. Both methods return
this
and can be called multiple times to register multiple handlers. Callbacks receive the live DOM element as both the first argument and as
this
, matching web component convention.
addConnectedCallback
Fires when the element is inserted into the DOM. Use it for initialization that requires DOM presence, such as reading layout dimensions, starting side effects that should only run while the element is mounted, or initializing third-party libraries that need a live element.
const panel = t.div({ class: 'panel' }, content);
panel.addConnectedCallback(function(el) {
// el (and `this`) is the DOM element. Layout is readable here
const { width } = el.getBoundingClientRect();
el.dataset.initialWidth = width;
});
document.body.append(panel.toElement()); // callback fires here
By default the callback fires once per
toElement()
call and is cleared when the element is removed. With
persist: true
in the tag options, all connected and disconnected callbacks re-fire on every cycle.
const tag = t.div({ persist: true }, content);
tag.addConnectedCallback(setup);
tag.addDisconnectedCallback(teardown);
tag.toElement(); // both callbacks re-fire on every insert/remove cycle
addDisconnectedCallback
Fires when the element leaves the DOM, after its signal effects have been cleaned up.
Use it for cleanup that signals cannot handle automatically,
such as clearing intervals and timers, destroying third-party library instances,
or removing portal elements.
By default the callback fires once and is not re-registered. With
persist: true
in the tag options, all disconnect callbacks re-fire on every removal.
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?, options?)
Server
Renders the component to HTML and embeds state as a JSON script block.
fn
is called as
fn(state, context)
where
context
comes from
options.context
(see below) and is never serialized. Uses
fn.name
by default server-side. Pass an explicit
name
for anonymous functions and when calling in the browser. Function names are not safe after minification.
name
must match what is used in
registerComponents
on the client. Throws if the component returns a non-element value or a Promise. Warns on lossy state values (Date, Map, Set, RegExp, undefined, function, Symbol, non-finite numbers, class instances); throws on unserializable ones (BigInt, circular references).
registerComponents(components, options?)
Client
Scans the page for components rendered by
renderForHydration
and mounts each one reactively. Each
fn
is called as
fn(state, context)
where
context
comes from
options.context
. Object keys are used as component names:
{ counter }
registers the function under
'counter'
. Must match what is passed in
renderForHydration
on the server. Issues a
console.warn
for unregistered component names and missing mount points. If the client component returns
null
or throws, warns or logs the error and leaves the SSR element in place. Defers hydration until
DOMContentLoaded
if called while the page is still loading. Components in dynamically fetched HTML fragments are hydrated automatically, without re-calling
registerComponents
. Returns
{ stop() }
to stop watching for new components.
options.context
Both
Non-serializable runtime bag passed as the second argument to every component invocation. The server provides its own via
renderForHydration
, the client provides its own via
registerComponents
, and the framework wires the appropriate one in for each environment. Use it for transport handles, local signals, identity, or anything else that cannot round-trip through JSON. Never embedded in the SSR script block. The framework also forwards context to HMR hot-swaps so a replaced component keeps its env wiring.
isBrowser
Both
true
in a browser environment,
false
in Node.js. Use to guard browser-only code that cannot go inside
effect()
, such as module-level expressions or
computed()
values.
Passing non-serializable runtime data via context
When a component needs runtime data the server cannot serialize (a live transport handle, an identity object, locally-created signals), pass an env bag as
options.context
. The framework forwards it to
fn
as the second argument. The server provides its own bag; the client provides its own; the two never cross the wire.
// shared/env.js. Two factories, same shape.
import { signal } from 'kensington';
export function makeServerEnv() {
return { userId: 'ssr', userName: signal(''), transport: null };
}
export function makeClientEnv({ userId, transport }) {
return { userId, userName: signal(''), transport };
}
// shared/app-page.js. Component takes (state, env).
import { t } from 'kensington';
export function appPage(state, env) {
return t.main([
t.span(env.userId),
t.button({ onclick: () => env.transport?.reconnect() }, 'Reconnect'),
]);
}
// server.js
import { renderForHydration } from 'kensington';
import { makeServerEnv } from './shared/env.js';
import { appPage } from './shared/app-page.js';
const env = makeServerEnv();
renderForHydration(appPage, {}, 'appPage', { context: env });
// client.js
import { registerComponents } from 'kensington';
import { connectLive } from 'kensington/live';
import { makeClientEnv } from './shared/env.js';
import { appPage } from './shared/app-page.js';
const transport = connectLive({ /* ... */ });
const env = makeClientEnv({ userId: getTabId(), transport });
registerComponents({ appPage }, { context: env });
Avoid alternatives that solve the same problem in worse ways: closing over
env
in a wrapper at the
renderForHydration
call site (workable but awkward),
setEnv
/
getEnv
singletons (module-mutable state that races on concurrent SSR), or passing signals through
state
(they lose their methods through
JSON.stringify
and the framework warns).
State shared across every connected browser.
liveSignal(initial, name, options?)
acts like
signal()
everywhere it is read but synchronizes through a server registry. Reads from one tab reflect writes from any tab. State persists across reloads and (with sqlite) across server restarts.
Best for state that partitions into small atomic values: cells in a spreadsheet, columns on a kanban board, presence per room. Direct
.set(value)
is last-write-wins;
.set(fn)
is atomic via compare-and-swap. Not suited to character-level concurrent text editing.
Setup. Three calls
Everything lives at the
kensington/live
subpath. Three setup calls cover the API. One per environment.
// One import covers the whole API.
import { liveSignal, connectLive, liveServer } from 'kensington/live';
1. Shared component file
The shared file runs on both server (SSR) and client.
liveSignal(initial, name)
returns a
Signal<T>
whose value is shared by name with every other connected client. Mix freely with regular
signal()
for local-only state and
computed()
for derivations.
Create one
liveServer
at startup. It owns the registry, the persistence adapter, and the WebSocket multiplexer.
live.attach(httpServer)
mounts the WebSocket handler on a Node HTTP server at the path configured by the
path
option (default
'/__kensington/live'
). The client's
connectLive({ url })
defaults to the same path, so no extra configuration is needed unless you override one side. Persistence defaults to memory. Pass
{ kind: 'sqlite', path }
for durability across restarts.
// server.js
import http from 'node:http';
import express from 'express';
import { renderForHydration } from 'kensington';
import { liveServer } from 'kensington/live';
import { todos } from './shared/todos.js';
const live = await liveServer({
persistence: { kind: 'sqlite', path: './data/live.db' }, // or { kind: 'memory' }
});
const app = express();
app.get('/', (req, res) => {
const state = { items: live.get('todos:list') ?? [] };
res.send(renderForHydration(todos, state));
});
const server = http.createServer(app);
await live.attach(server);
server.listen(3000);
On Bun, the upgrade dance and the WebSocket handlers go through Bun's default-export object. Pass the request through
data: { req }
so
onConnect(ws, req)
can read headers off it.
// server.ts. Bun + Hono.
import { Hono } from 'hono';
import { renderForHydration } from 'kensington';
import { liveServer } from 'kensington/live';
import { todos } from './shared/todos.js';
const live = await liveServer({
persistence: { kind: 'sqlite', path: './data/live.db' },
});
const app = new Hono();
app.get('/', c => {
const state = { items: live.get('todos:list') ?? [] };
return c.html(renderForHydration(todos, state));
});
export default {
port: 3000,
fetch(req, server) {
if (new URL(req.url).pathname === '/__kensington/live' && server.upgrade(req, { data: { req } })) {
return;
}
return app.fetch(req, { server });
},
websocket: live.bunWebsocket(),
};
3. Client
Open one WebSocket connection at boot, then register the same components as you would for a non-live SSR app.
connectLive()
with no arguments uses the same default path as
liveServer
(
'/__kensington/live'
). Pass
url
only if the server mounts at a different path or the WebSocket lives on a different host. The returned transport handle has a
status
field that is a reactive
Signal<ConnectionStatus>
you can read directly, transform, or pass into a component as data. The matching
liveServer().status
on the server is always
'connected'
, so the same Signal type appears at both ends.
// client.js
import { registerComponents } from 'kensington';
import { connectLive } from 'kensington/live';
import { todos } from './shared/todos.js';
const live = connectLive(); // defaults match liveServer
registerComponents({ todos });
Naming. The scoping mechanism
The runtime stays oblivious to URL, user, room, or document. The
name
string IS the scope. Common patterns:
'counter:global'
— one shared value across the whole app
drafts:user:<userId>
— per-user state, isolated by id
doc:42:title
— per-document property
cell:sheet:<sheetId>:<address>:raw
— fine-grained per-cell state
chat:room:<roomId>:messages
— per-room list
Two calls to
liveSignal
with the same name in the same process return the same Signal instance. The name is the identity. Across files, modules, even across server-side effects and the shared component, identical names mean the same registry entry.
Persistence backend and per-signal policy
Two orthogonal decisions. The
liveServer
option selects WHERE persisted writes land. The per-signal
persist
option (default
false
) decides WHICH signals use the backend.
liveServer persistence
Behavior
Cost
{ kind: 'memory' }
Default. State held in process memory. Lost on restart. Fine for demos and tests.
Zero deps.
{ kind: 'sqlite', path, flushInterval? }
Backend stores values to a SQLite database. Writes are debounced (default 250ms) and grouped in a transaction. Reads come from an in-memory mirror loaded on startup.
Requires
better-sqlite3
(optional peer dep).
Per-signal
persist
on
liveSignal(initial, name, { persist })
mirrors the
persist
flag on tag options. Default false. The cheap option is the default.
persist: true
is the explicit opt-in for archival behavior.
// Transient. Default. Lives in memory only. Server restart wipes it.
// Dropped from the server registry 30 seconds after the last subscriber leaves.
const cursor = liveSignal({ x: 0, y: 0 }, `cursor:user:${tabId}`);
// Persisted. Writes flow to the configured backend. The registry entry
// stays alive until an explicit live.delete(name).
const sticky = liveSignal({ x, y, text }, `sticky:${id}`, { persist: true });
First declaration wins. The policy is a property of the name, not of the call site. If one call passes
{ persist: true }
and another passes
{ persist: false }
for the same name, the first wins and a once-per-name warning fires.
Write policy. canRead and canWrite
Two layers. A global
canWrite
on
liveServer
applies to every client write. A per-signal
canWrite
on
liveSignal({ canWrite })
applies only to that name. Both must allow. Each defaults to
'any'
. Server-side writers (
live.set
, server-side
liveSignal.set
) bypass both checks.
type CanWrite =
| 'any' // default. any authenticated client may write.
| 'server-only' // no client may write. server writers only.
| ((name: string, ctx: any, transition: { prev, next }) => boolean); // custom predicate.
The function form gets the same
ctx
returned by
onConnect
plus the proposed transition. Use it to validate identity, value, or the relationship between the old and new value in one call.
Don't use isBrowser inside canWrite.
The predicate always runs on the server, where
isBrowser
is
false
.
canWrite: !isBrowser
evaluates to
true
and allows all client writes (the opposite of the intent). Use
canWrite: 'server-only'
for "no client can write."
Updates that depend on the current value
When the new value depends on the current value (counter increment, append to a list, toggle a flag, merge into an object), pass a function to
.set
instead of a value.
Under concurrent writes from multiple clients, the function form converges.
fn
runs against the latest server value, so each client sees the others' updates instead of overwriting them.
.set(value)
on the same name races with last-write-wins.
Both
.set(value)
and
.set(fn)
return a
Promise<void>
that resolves once the server confirms or rejects with a structured
LiveSetRejected
Error on permanent failure (
canWrite
denied, value not serializable, transport disconnected, retry cap exhausted). The server-authoritative value rolls back the local Signal before the rejection fires, so
sig.value
inside
.catch
already reflects the truth. Fire-and-forget callers can ignore the Promise; the library silences unhandled-rejection warnings for unawaited returns.
// Surface a rejection to the user via a toast.
try {
await seat.set(myTabId);
} catch (err) {
if (err instanceof Error && err.name === 'LiveSetRejected') {
toast(`${err.signalName}: ${err.reason}. owned by ${err.authoritativeValue}`);
}
}
When to use which form
.set(value)
for direct assignment. Theme changes, status flags, selection changes, anything that overwrites without caring what was there before.
.set(fn)
when the new value depends on the current one. Lists, objects, counters, anything where read-modify-write would be a race.
Caveats
The function must be pure AND synchronous.
Side effects inside fn will run multiple times if the server reports conflicts and the library retries. Async functions silently corrupt the value because the Promise serializes to
{}
on the wire. The lint plugin's
kensington/no-async-set
rule catches the syntactic async case.
Large values pay the bandwidth.
With
.set(fn)
, the full computed value travels over the wire each retry. Fine for normal-sized collections; consider a server-side writer pattern for genuinely large state.
Trust model is cooperative.
Any authenticated client can write any value via
.set(fn)
. For state where the server must validate transitions (auctions, money, voting), pair
.set(fn)
with a per-signal
canWrite
predicate.
Connection status
The connection-status signal lives on the transport handles. Read
connectLive().status
on the client and
liveServer().status
on the server. Both are reactive
Signal<ConnectionStatus>
values yielding one of
'connecting'
,
'connected'
,
'reconnecting'
, or
'disconnected'
(the server is always
'connected'
).
A shared component renders the pill from a signal passed in as data. Wire it at both entry points so SSR shows the pill without a reflow on hydration.
// shared/status-pill.js
import { t } from 'kensington';
export function statusPill(status) {
return t.span({ class: status.transform(s => `pill pill-${s}`, 'pill-class') }, status);
}
// server.js
const live = await liveServer({ /* ... */ });
res.send(renderForHydration(state => statusPill(live.status), state, 'statusPill'));
// client.js
const live = connectLive();
registerComponents({ statusPill: state => statusPill(live.status) });
Server-side liveSignal as a reactive subscription
Outside of
renderForHydration
,
liveSignal(initial, name)
on the server returns a long-lived Signal that subscribes to registry updates. Client writes, server-side
live.set
, and writes from other server-side
liveSignal
instances all propagate into the local Signal. Wrap an
effect()
around it to react.
import { effect } from 'kensington';
import { liveServer, liveSignal } from 'kensington/live';
const live = await liveServer({ persistence: { kind: 'sqlite', path: './data.db' } });
// Top-of-server boot. Outside any SSR call.
const counter = liveSignal(0, 'counter', { persist: true });
effect(() => {
// Re-runs every time anyone writes to 'counter' (client or server).
metrics.gauge('counter', counter.get());
audit.log('counter changed', counter.get());
});
Same call shape on both sides. Inside
renderForHydration
, a fresh per-request Signal seeded from the registry. Outside SSR, a long-lived Signal that subscribes to registry updates.
The auto-unsubscribe trap
When the last local subscriber to a live signal goes away, the transport unsubscribes from the server. Usually correct (viewport virtualization, mount/unmount). But if the rendering chain that subscribes can drop transiently and the signal should stay subscribed for the component's lifetime, pin a persistent local subscriber:
import { effect, isBrowser } from 'kensington';
import { liveSignal } from 'kensington/live';
export function room(state) {
const presence = liveSignal({ users: [] }, 'presence:list');
const cursors = liveSignal({}, 'cursors:everyone');
let keepAlive = null;
if (isBrowser) {
keepAlive = effect(() => {
presence.get();
cursors.get();
});
}
// ... rest of the component ...
root.addDisconnectedCallback(() => {
if (keepAlive !== null) { keepAlive.stop(); }
});
return root;
}
Use whenever the live signal's lifetime should equal the component's, not the rendering chain's. Presence, shared cursors, chat rooms, anywhere "stay subscribed even if nothing renders this right now" is the intended semantic.
Where liveSignals are created
Per-entity
liveSignal
instances (per-user cursors, per-cell raw values, per-document metadata) are typically read from multiple components. They must therefore be created outside any reactive callback. The general rule is
Don't read a signal outside the scope where it was created
; the application to live signals is direct.
For live signals specifically, the first
liveSignal(initial, name)
call is what subscribes the name to the server. Create your live signals at the component scope, before the first render reads them, so every name is subscribed in time.
Best Practices
A few common mistakes and how to avoid them.
Use a signal for any value that needs to change after render
Attributes, content, and
prop
values are read once when the tag is built. A plain variable passed at that point is a snapshot. Changing it later has no effect on the DOM. Wrap the value in a signal so updates flow through automatically.
// Problem: the attribute is read once at creation. Changing the variable does nothing.
let submitting = false;
const btn = t.button({ disabled: submitting }, 'Submit').toElement();
submitting = true; // button is still enabled
// Fixed: the attribute updates whenever the signal changes.
const submitting = signal(false);
const btn = t.button({ disabled: submitting }, 'Submit').toElement();
submitting.set(true); // button becomes disabled
The same applies to text content (
t.p(mySignal)
) and
prop
values (
prop: { value: mySignal }
).
Pass a key to signals created inside a computed
A
computed()
or
transform()
callback re-runs every time its dependencies change. A bare
signal()
call inside the callback creates a brand-new instance on each re-run. Local interactive state resets to its initial value and the previous instance becomes a sleeping orphan in the devtools Signals tab.
Pass a stable
key
as the second argument to
signal()
to scope the signal to the surrounding
computed
. The same key returns the same instance across re-runs, so local state persists. Use the item identity (typically its id) as the key. The same applies inside
mapWithKey
's mapFn since it wraps an internal computed.
// Works, but local state resets on every outer re-render. The library logs a
// console.warn pointing to the keyed form.
const list = items.mapWithKey('id', item => {
const highlight = signal(false);
return t.li({ class: highlight.transform(v => v ? 'on' : '') }, [
t.button({ onclick: () => highlight.set(true) }, item.label),
]);
});
// Best: keyed signal. Same instance across re-runs, state persists, and the
// signal is stopped automatically when the item leaves the list.
const list = items.mapWithKey('id', item => {
const highlight = signal(false, item.id);
return t.li({ class: highlight.transform(v => v ? 'on' : '') }, [
t.button({ onclick: () => highlight.set(true) }, item.label),
]);
});
For derived values that depend only on data already on the item, lifting the signal onto the item object is also a good choice. It avoids the key bookkeeping and makes the per-item state explicit in the data model.
// Alternative: store reactive state on the item itself.
function makeItem(id, label) {
const done = signal(false);
const cls = done.transform(d => d ? 'done' : 'open');
return { id, label, done, cls };
}
const items = signal([makeItem(1, 'Buy milk'), makeItem(2, 'Walk dog')]);
const rows = items.mapWithKey('id', item => t.li({ class: item.cls }, item.label));
Don't read a signal outside the scope where it was created
A signal created inside a
computed
,
effect
,
transform
, or
mapWithKey
mapFn belongs to that reactive scope. Reading it from outside is the bug. The keyed-signal pattern above works precisely because per-row state is only read by tags built inside the row — same scope, same lifecycle.
The failure mode is subtle: the signal looks fine at first read, but when the surrounding callback re-runs without the key (the row disappears, the outer state changes), the keyed sweep stops the signal. Any external reader still holding a reference now subscribes to a dead signal. Kensington fires an
out-of-scope-reactive-reference
warning when it can detect this at read time.
// Wrong. The signal is created inside the mapFn (a reactive scope), but a
// header component elsewhere also reads it. When the row leaves the list,
// the signal stops; the header is left with a dead reference.
const rows = items.mapWithKey('id', item => {
const expanded = signal(false, item.id);
exposeExpandedFlag(item.id, expanded); // ← reader outside the mapFn
return t.li({ class: expanded.transform(v => v ? 'on' : '') }, item.label);
});
// Right. The signal needs to live longer than any single row, so create
// it outside any reactive scope. The mapFn just reads it.
const expandedFlags = new Map(); // module scope. Outside any callback.
for (const id of knownIds) { expandedFlags.set(id, signal(false)); }
const rows = items.mapWithKey('id', item => {
const expanded = expandedFlags.get(item.id); // lookup, not creation
return t.li({ class: expanded.transform(v => v ? 'on' : '') }, item.label);
});
The diagnostic question.
Where will this signal be read?
Only inside the surrounding callback (and its descendants) → safe to create inline. Pass a key so the same instance is reused across re-runs.
From outside that callback too (other components, other effects, module-level code) → create the signal outside the callback. If you need lazy creation, do the lazy creation outside any reactive scope (a one-shot loop in
addConnectedCallback
, or a top-level effect that defers via
queueMicrotask
).
Same rule for
liveSignal
. Per-user cursors, per-cell raw values, per-document metadata are usually read by multiple components, so the signal needs to outlive any one reactive callback. Create the names outside the reactive scope before the first render that reads them.
Use a named function for event handlers that read mutable state
When you use
mapWithKey
, the mapFn runs once per key and the tag is cached. Event handlers attached inside the mapFn therefore close over whatever variables existed at first render. A named function defined outside the mapFn that reads module-level state at call time always sees the current value.
// Inline arrow: closes over 'mode' at first render. Cached, never updates.
let mode = 'view';
const rows = items.mapWithKey('id', item =>
t.li({ onclick: () => handleClick(item.id, mode) }, item.label)
);
// Named function: reads mode at click time, so it always reflects the
// current value even though the tag itself is cached by mapWithKey.
let mode = 'view';
function handleClick(e) { doSomething(e.currentTarget.dataset.id, mode); }
const rows = items.mapWithKey('id', item =>
t.li({ data: { id: item.id }, onclick: handleClick }, item.label)
);
mode = 'edit'; // all items see 'edit' when clicked, no re-render needed
Use mapWithKey for lists that may change
Without a key, every re-render builds fresh DOM nodes for every item. With
mapWithKey
, the mapFn runs once per id and the tag is cached. Reorders, additions, and removals reuse existing DOM nodes; only new items pay for tag construction.
// Problem: every item rebuilds on every update.
const rows = items.transform(list => list.map(item => t.li(item.label)));
// Fixed: nodes are reused. Only added items pay for construction.
const rows = items.mapWithKey('id', item => t.li(item.label));
Devtools
Kensington ships a devtools overlay for inspecting signals, computed signals, effects, and DOM bindings at runtime. It is a floating panel that can be toggled with a button in the bottom-right corner of the page. A pop-out button (↗) in the panel header opens it in a separate window so it can sit alongside the page being developed. The popup reconnects automatically when the main page reloads.
Setup
Import
kensington/devtools
in your dev entry point. It mounts the panel overlay in one step. Wrap it in your bundler's dev-only guard so it tree-shakes out of production builds.
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.
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.
Unkeyed signal() inside a computed loses local state on every outer re-render.
A fresh signal is created on each outer re-run and starts at the initial value. Focus, scroll, input values, and text selection are preserved across the surrounding node swap, but the signal itself resets. Pass a stable key so the same signal is reused across re-runs.
Unkeyed computed() and .transform() inside a computed are silent when the inner value only feeds an attribute, class, text, or prop slot inline.
Those consumers tear down alongside the inner on outer re-run, so no identity is leaked. The
computed-in-computed
/
transform-in-computed
warning fires only when a user
effect
or user
computed
subscribes to the inner. In that case pass a stable key so the same inner is reused across re-runs and the user subscription stays valid.
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 automatically, on the next tick. 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.
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.
signal.mapWithKey('id', mapFn)
returns a signal of rendered list items. The mapFn runs once per id the first time the item is seen; the resulting tag is cached and reused on every subsequent render. Adding or removing items reorders the existing DOM nodes rather than rebuilding them.
Each row has its own edit mode that does not belong on the outer data array.
signal(false, item.id)
scopes the per-row state to the surrounding computed so the same signal instance is reused across renders. The inner
computed(() => editing.get() ? input : label, item.id)
swaps the visible element when
editing
toggles. Keying it gives the inner computed a stable Signal identity across outer re-runs and lets it react to
editing
independently of changes to the items array. Both are stopped automatically when their row leaves the list.
Click a row to edit, blur to save. Adding or removing items elsewhere in the list does not collapse a row that is currently being edited, because each row's
editing
signal kept its identity across the re-render.
Dark mode
effect
is the right tool when a signal needs to drive something outside the reactive tree. Here it toggles a class on
document.documentElement
. The button label is a
computed
that flips with the signal.
.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.mapWithKey('id', item => t.li(item.title))),
]).toElement()
);
Sortable table
Two signals, sort column and sort direction, drive both the data rows and the column headers. A
computed
produces the sorted list and
signal.mapWithKey
renders one
<tr>
per person. The mapper runs once per name; on every sort the same
<tr>
instances are reordered rather than rebuilt. Each header has its own
computed
that tracks only the signals it actually reads. The active header tracks both, inactive headers track only
sortCol
. Stale subscriptions are cleaned up automatically between runs.
import { t, signal, computed } from 'kensington';
const people = [
{ name: 'Alice', age: 32, role: 'Admin' },
{ name: 'Bob', age: 28, role: 'Editor' },
{ name: 'Carol', age: 41, role: 'Viewer' },
{ name: 'Dave', age: 25, role: 'Editor' },
];
const sortCol = signal('name');
const sortAsc = signal(true);
function compare(a, b, col) {
const av = a[col];
const bv = b[col];
if (typeof av === 'number' && typeof bv === 'number') {
return av - bv;
}
return String(av).localeCompare(String(bv));
}
// A computed sorted list. mapWithKey runs the row builder once per person and
// reuses the same <tr> instances on every sort. Sorting reorders existing DOM
// nodes rather than rebuilding them.
const sorted = computed(() => {
const col = sortCol.get();
const asc = sortAsc.get();
return [...people].sort((a, b) => asc ? compare(a, b, col) : -compare(a, b, col));
});
function sortHeader(col, label) {
// Active column subscribes to both sortCol and sortAsc. Inactive columns subscribe
// only to sortCol, because sortAsc.get() is never reached. Stale subscriptions are
// cleaned up automatically between runs.
const heading = computed(() => sortCol.get() === col
? `${label} ${sortAsc.get() ? '↑' : '↓'}`
: label);
return t.th({
style: { cursor: 'pointer' },
onclick: () => {
if (sortCol.get() === col) {
sortAsc.set(v => !v);
} else {
sortCol.set(col);
sortAsc.set(true);
}
},
}, heading);
}
document.body.append(
t.table([
t.thead(t.tr([
sortHeader('name', 'Name'),
sortHeader('age', 'Age'),
sortHeader('role', 'Role'),
])),
t.tbody(sorted.mapWithKey('name', p =>
t.tr([t.td(p.name), t.td(String(p.age)), t.td(p.role)]),
)),
]).toElement()
);
Static HTML tab switcher
When the page is mostly static HTML, a signal and a few
effect()
calls are enough to add interactivity without rebuilding the markup with Kensington. Here a signal holds the active tab key, and each tab button and content panel reads the signal in its own
effect
to update its class. The initial active tab is read from the HTML itself so the page works before JavaScript runs.
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 => {
const alice = { name: 'Alice', role: 'admin' };
const guest = { name: 'Guest', role: 'viewer' };
return u.name === 'Guest' ? alice : guest;
}),
}, 'Toggle login'),
// No provider. Reads from the default signals.
t.section([userBadge(), themeCard('Default')]),
// Static provide. Always dark regardless of the toggle.
ThemeContext.provide('dark', () =>
t.section([userBadge(), themeCard('Always dark')]),
),
// User overridden. The login toggle does not affect this subtree.
UserContext.provide({ name: 'Bob', role: 'editor' }, () =>
t.section([userBadge(), themeCard('Bob is always the user here')]),
),
]);
document.body.append(app.toElement());
useReducer
useReducer
centralises state transitions behind a
dispatch
function. Wrap
signal.set
with a reducer to get the same pattern: complex state machines stay readable and the call sites only send action objects.
// use-reducer.js
import { signal } from 'kensington';
function useReducer(reducer, initialState) {
const state = signal(initialState);
function dispatch(action) {
state.set(s => reducer(s, action)); // updater form: reducer always sees the latest state
}
return { state, dispatch };
}
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 => {
if (l) { return t.p('Loading...'); }
const err = error.get();
return err ? t.p({ class: 'error' }, err) : t.pre(JSON.stringify(data.get(), null, 2));
}),
]).toElement()
);
Portal
React's
createPortal
renders a subtree into a DOM node outside the parent component. Kensington has no portal API because
.toElement()
returns a real DOM node. Mount it wherever you want. A two-line helper appends the node and returns a remover. Wrap the call in an
effect
to tie the mount/unmount lifecycle to a signal.
Kensington already takes a style object on every tag (
{ style: { backgroundColor: 'red' } }
). What inline styles can't do is pseudo-selectors, media queries, and reuse across components.
styled(tag, styles)
fills the gap. It takes a tag closure and a style object (same camelCase keys as the built-in
style
attribute, plus nested keys for pseudo-selectors and at-rules), injects a class into a shared stylesheet, and returns a new tag closure.
// styled.js
let _id = 0;
let _style;
const sheet = () => (_style ??= document.head.appendChild(document.createElement('style')));
const kebab = s => s.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
function toCss(selector, styles) {
const decls = [];
let nested = '';
for (const [k, v] of Object.entries(styles)) {
if (v && typeof v === 'object') {
nested += k.startsWith('@')
? `${k} { ${toCss(selector, v)} } ` // @media, @supports, ...
: `${toCss(selector + k, v)} `; // :hover, > .child, &.primary, ...
} else if (v !== null && v !== undefined && v !== false) {
decls.push(`${kebab(k)}:${v}`);
}
}
return (decls.length ? `${selector} { ${decls.join(';')} } ` : '') + nested;
}
// A plain attrs object is anything that isn't a tag, signal, array, or null/primitive.
// Matches how Kensington's own tag closures disambiguate (attrs, content) from (content).
function isAttrs(x) {
return x !== null
&& typeof x === 'object'
&& !Array.isArray(x)
&& !x._isKensingtonTag
&& !x._isKensingtonSignal;
}
export function styled(tag, styles) {
const className = `k-${++_id}`;
sheet().textContent += toCss(`.${className}`, styles);
return (...args) => {
const hasAttrs = args.length > 0 && isAttrs(args[0]);
const attrs = hasAttrs ? args[0] : {};
const rest = hasAttrs ? args.slice(1) : args;
const merged = { ...attrs, class: [className, attrs.class].filter(Boolean) };
return tag(merged, ...rest);
};
}
The returned tag is a plain Kensington tag closure. It takes the same arguments any tag does,
and any extra props the caller passes (attributes, event handlers, content) flow through unchanged.
Passing a styled tag as the first argument to
styled
composes them. Both classes apply, and later-defined styles win by source order in the stylesheet. No new helper, no new API.
For per-call variants, declare modifier classes inside the styles object and let the caller pick one. Combine with signal-derived
class
arrays for reactive variants.
Reactive
class
values are already first-class in Kensington. Flipping
level.set('warn')
swaps the modifier class on the live element. The static base styles live in the generated class once.
useId
Generates a unique, stable ID for pairing form labels with inputs. A module-level counter increments once per call. On the server it produces the same sequence on every request, so IDs in SSR output and client hydration match as long as components are called in the same order.
// use-id.js
let _id = 0;
function useId(prefix = 'k') {
return `${prefix}-${++_id}`;
}
import { t } from 'kensington';
import { useId } from './use-id.js';
function labeledInput(label, type = 'text') {
const id = useId();
return t.div({ class: 'field' }, [
t.label({ for: id }, label),
t.input({ id, type }),
]);
}
document.body.append(
t.form([
labeledInput('Full name'),
labeledInput('Email', 'email'),
labeledInput('Password', 'password'),
t.button({ type: 'submit' }, 'Sign up'),
]).toElement()
);
Integrations
htmx
Pass
'hx'
to
additionalNamespaces
to allow
hx-*
attributes. Alpine.js uses
'x'
.
import Kensington from 'kensington';
const t = new Kensington({ additionalNamespaces: ['hx'] });
// Live search: htmx swaps in the result fragment
t.div([
t.input({
type: 'search',
name: 'q',
placeholder: 'Search...',
hx: {
get: '/search',
trigger: 'input changed delay:300ms',
target: '#results',
},
}),
t.ul({ id: 'results' }),
]);
// The partial route returns just the <li> items (htmx swaps them into the <ul>)
app.get('/search', async (req, res) => {
const rows = await db.search(req.query.q);
res.send(rows.map(r => t.li(r.name)).join('\n'));
});
Tailwind CSS
The
class
array is a natural fit for Tailwind. Falsy entries are dropped, so conditional classes don't need ternaries or string concatenation.
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 effects are cleaned up automatically when the element is removed from the DOM.
D3
Use Kensington to build the SVG container, then hand it to D3 for data-driven rendering. Wrap the D3 draw logic in an
effect
so the chart redraws automatically whenever the signal holding the data changes.
import { t, signal, effect } from 'kensington';
import * as d3 from 'd3';
const data = signal([12, 40, 28, 55, 33, 20, 47]);
const W = 500, H = 220;
const m = { top: 10, right: 10, bottom: 30, left: 34 };
const svg = t.svg({ width: W, height: H, viewBox: `0 0 ${W} ${H}` }).toElement();
document.getElementById('chart').replaceChildren(svg);
effect(() => {
const values = data.get();
const x = d3.scaleBand()
.domain(values.map((_, i) => i))
.range([m.left, W - m.right])
.padding(0.2);
const y = d3.scaleLinear()
.domain([0, d3.max(values)])
.nice()
.range([H - m.bottom, m.top]);
const chart = d3.select(svg);
chart.selectAll('*').remove();
chart.append('g')
.attr('transform', `translate(0,${H - m.bottom})`)
.call(d3.axisBottom(x).tickFormat(i => `Day ${i + 1}`));
chart.append('g')
.attr('transform', `translate(${m.left},0)`)
.call(d3.axisLeft(y).ticks(5));
chart.selectAll('rect')
.data(values)
.join('rect')
.attr('x', (_, i) => x(i))
.attr('y', d => y(d))
.attr('width', x.bandwidth())
.attr('height', d => y(0) - y(d))
.attr('fill', 'steelblue');
});
// Replace the data to redraw the chart.
document.getElementById('refresh').addEventListener('click', () => {
data.set(Array.from({ length: 7 }, () => Math.round(Math.random() * 60) + 5));
});
D3 owns the contents of the SVG element. Kensington owns everything outside it. The surrounding layout, controls, and any other reactive UI on the page belong to Kensington. The two libraries operate in separate parts of the DOM and do not conflict.
Build systems
The recommended setup runs the full Kensington build in development (with runtime
validation
on) and the
slim build
in production (with no validation). The
Vite example
on the home page shows the pattern. The same idea works in every bundler that supports module aliasing.
Rollup
Use
@rollup/plugin-alias
to swap the import in production.
@rollup/plugin-replace
sets
process.env.NODE_ENV
so application code can pick a
validationLevel
at build time.
// rollup.config.js
import alias from '@rollup/plugin-alias';
import nodeResolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
const production = process.env.NODE_ENV === 'production';
export default {
input: 'src/main.js',
output: { file: 'dist/bundle.js', format: 'es' },
plugins: [
nodeResolve(),
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify(production ? 'production' : 'development'),
}),
production && alias({
entries: [{ find: 'kensington', replacement: 'kensington/dist/slim' }],
}),
].filter(Boolean),
};
// src/t.js
import Kensington from 'kensington';
export const t = new Kensington({
validationLevel: process.env.NODE_ENV === 'production' ? 'off' : 'error',
});
Run with
NODE_ENV=production rollup -c
for the slim bundle,
rollup -c
for the full one.
esbuild
esbuild has built-in support for both aliasing and environment-variable replacement via
alias
and
define
. No plugins required.
// 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.
Creates a read-only signal derived from other signals. Re-evaluates
fn
synchronously whenever any signal read inside it changes. Exposes
.stop()
to unsubscribe and freeze the value. The keyed form is documented under
Keyed forms
.
Keyed forms inside a computed
Pass a stable
key
as the second argument to scope the instance to the surrounding
computed
callback. The same key returns the same instance across outer re-runs. The instance is stopped automatically when its key leaves the list, and the whole registry is torn down when the owning computed is stopped.
Form
Identity across outer re-runs
signal(initial, key)
Same key returns the same signal. Only the first call's initial is used.
computed(fn, key)
Same key returns the same inner instance. The fn closure is refreshed each run so captured values stay current.
signal.transform(fn, key)
Same lifecycle as
computed(fn, key)
. Single-source.
type SignalKey = string | number | object | symbol;
Without a key these forms still work, but the instance is re-created on every outer re-run, local state resets, and a warning is logged. A keyed instance reference must not escape its owner. The
no-out-of-scope-reactive-reference
ESLint rule and a runtime warning catch escapes. See
per-item local state
in the guide.
Keyed list mapper. The first argument extracts the key (a function or a property-name string).
mapFn
runs once per key the first time the key is seen and the resulting tag is cached and reused on later renders. Call it at the same scope as
signal()
, not inside a
computed
or
effect
callback. See
keyed lists
in the guide.
Runs
fn
immediately and re-runs it whenever any signal read inside it changes. Synchronous
.set()
calls in the same turn are batched into one re-run. During SSR (
renderForHydration
) it is a no-op.
Method
Description
.pause()
Unsubscribes temporarily.
.resume()
Re-runs fn and re-establishes subscriptions.
.stop()
Permanently destroys the effect.
resume()
after
stop()
is a no-op.
prop key
Assigns DOM properties directly (
el[name] = value
) instead of
setAttribute
. Accepts a plain object whose values are static or
ReadonlySignal
. Ignored in
.toString()
. Property existence and writability are validated at render time and reported via
validationLevel
.
import { renderForHydration } from 'kensington';
renderForHydration<S, C = unknown>(
fn: (state: S, context: C) => ContentTag | ContentTag[] | null | undefined,
state: S,
name?: string,
options?: { context?: C },
): LiteralTag
Renders a synchronous component to an HTML string and embeds
state
as a
<script type="application/json">
block for client hydration.
Argument
Description
fn
The component function. Receives
state
and
context
as positional arguments. Signal effects are suppressed during the call.
state
A plain serializable object. Values that cannot survive
JSON.stringify
warn or throw.
name
Defaults to
fn.name
server-side. Pass an explicit string in the browser and for anonymous functions. Must match the key used by
registerComponents
.
options.context
Non-serializable runtime bag passed to
fn
as its second argument. Use it for transport handles, local signals, identity, or anything else that cannot round-trip through JSON. Never embedded in the SSR script block. The client supplies its own context via
registerComponents
.
Hydrates all server-rendered instances in the page, replacing each matching
<script type="application/json" data-k-component="…">
block with live reactive DOM, and watches for components inserted later. Returns
{ stop() }
to halt auto-hydration. Pass
options.context
to thread a non-serializable runtime bag into every registered component as its second argument. The framework forwards the same context to HMR hot-swaps.
The
kensington/live
subpath ships a server-synchronized signal primitive. With no transport registered,
liveSignal
returns a regular
signal()
so shared components stay unit-testable. See the
live signals guide
for setup, naming, and usage patterns.
Returns a
Signal<T>
shared by name across connected clients. Subsequent calls with the same name in the same process return the same instance. Values must round-trip through
JSON.stringify
(no circular references, BigInts, Maps, Sets, Dates, class instances, functions, or Symbols). Unserializable writes are rejected with a once-per-name warning so local state stays in sync with the broadcast.
persist: false
(default) is in-memory only and dropped 30 seconds after the last subscriber leaves.
persist: true
writes through to the configured backend (memory or sqlite) and keeps the entry until
live.delete(name)
. First declaration per name wins; mismatched flags warn.
canWrite
defaults to
'any'
(authenticated clients may write).
'server-only'
rejects all client writes. A function predicate validates writes against identity and the proposed transition. Layered with the global
canWrite
on
liveServer
; both must allow.
.set under multiple clients
// Both forms return Promise<void>.
sig.set(value: T): Promise<void>
sig.set(fn: (prev: T) => T): Promise<void>
Same call sites as a regular signal. The return shape and the rejection contract are different.
.set(value)
races against concurrent writers from other clients. Last-write-wins.
.set(prev => next)
converges under concurrent writes (the library retries against the latest server value when another client wrote first).
fn
may run more than once, so it must be pure.
Both forms resolve once the server confirms and reject with a
LiveSetRejected
Error on rejection. The server-authoritative value is already applied to the local Signal via
_setFromRemote
before the rejection fires, so
sig.value
inside
.catch
reflects the truth, not the optimistic value.
Fire-and-forget callers can ignore the Promise. The library suppresses unhandled-rejection warnings for unawaited / un-
.catch
'd returns;
await
and explicit
.catch
still see the rejection.
Prefer the updater form whenever the new value depends on the current value.
LiveSetRejected
type LiveSetReason =
| 'forbidden' // canWrite predicate rejected the write
| 'conflict' // CAS lamport mismatch (retried internally; surfaced via 'retries-exhausted')
| 'unserializable' // value can't round-trip JSON, or contains NaN / Infinity
| 'disconnected' // transport is 'disconnected' or the socket dropped mid-flight
| 'retries-exhausted' // CAS write retried MAX_CAS_RETRIES times without converging
| 'unsubscribed' // signal was .stop()'d while a CAS retry was in flight
| 'aborted'; // transport was close()'d while the write was in flight
interface LiveSetRejected<T = unknown> extends Error {
name: 'LiveSetRejected';
signalName: string;
reason: LiveSetReason;
attemptedValue: T | undefined;
authoritativeValue: T | undefined;
}
Narrow via
err instanceof Error && err.name === 'LiveSetRejected'
.
attemptedValue
is what the caller tried to write;
authoritativeValue
is the server's truth at the moment of rejection (already applied to
sig.value
before the rejection fires).
try {
await seat.set(myTabId);
} catch (err) {
if (err instanceof Error && err.name === 'LiveSetRejected') {
const e = err as LiveSetRejected<string>;
toast(`${e.signalName}: ${e.reason}. owned by ${e.authoritativeValue}`);
}
}
Connection status
type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected'
Read off the handle returned by
connectLive()
or
liveServer()
. Server-side
.status
is always
'connected'
. Both are reactive
Signal<ConnectionStatus>
values.
Opens a WebSocket and registers the transport. Call once at app boot,
before
any
liveSignal
call. Reconnect is automatic with exponential backoff. See the
live signals guide
for setup.
Options
Option
Description
url
WebSocket URL. Defaults to
'/__kensington/live'
, matching
liveServer
's default path. Override both together if you need a different path.
reconnect
Reconnect policy.
initialDelay
(ms, default 250) and
maxDelay
(ms, default 30 000) set the exponential-backoff window.
maxRetries
(default
Infinity
) caps attempts; once exhausted, status transitions to
'disconnected'
and
reconnect()
resets the counter.
onStatus(status)
Called each time the connection status changes. Same values as the reactive
transport.status
signal; use the callback form when you need the change outside a reactive context.
onFrame(direction, frame)
Fires per WebSocket frame.
direction
is
'out'
or
'in'
. The
type
field on
frame
is the stable surface for inspection. Useful for protocol-level debugging.
Transport handle
Method / property
Description
status
Reactive
Signal<ConnectionStatus>
. Read to drive connection-state UI.
close()
Terminal. Stops reconnect attempts and closes the WebSocket. No way back without a fresh
connectLive()
.
disconnect()
Drop the WebSocket and stay disconnected. Call
reconnect()
to come back.
reconnect()
Drop and immediately re-open. Subscriptions survive. Backoff resets.
pauseSend()
/
resumeSend()
Buffer outgoing writes locally; reads still apply. Status stays at
'connected'
.
unsubscribe(name)
Stop following one name. The local Signal stays valid; updates stop arriving.
Creates the server-side runtime.
await
it at startup. Server-side
.set
calls bypass
canWrite
; it gates client writes only.
Options
Option
Description
persistence
{ kind: 'memory' }
(default) keeps state in-process; lost on restart.
{ kind: 'sqlite', path: './data/live.db' }
persists names declared with
persist: true
across restarts. Optional
flushInterval
(ms, default 250) debounces writes so bursts coalesce into single transactions.
canRead(name, ctx)
Per-name read gate called on subscribe. Return
false
to reject; the client receives
MSG_ERROR
.
ctx
is the object returned by
onConnect
.
canWrite
Global write policy applied before any per-signal
canWrite
check.
'any'
(default) allows all client writes.
'server-only'
blocks all client writes. A function
(name, ctx, transition) => boolean
gates per-name. Rejected writes return
MSG_SET_FAIL
to the originating client.
onConnect(ws, req)
Called once per WebSocket open. Return a context object (sync or async) threaded into
canRead
,
canWrite
,
onSocketClose
, and
contextFor()
. Use it to read cookies, validate tokens, and attach a user id.
onSocketClose(ctx, ws)
Called once per WebSocket close with the
ctx
from
onConnect
. Release per-user state here (presence slots, locks, in-flight writes)
without waiting for transient-drop TTLs.
path
WebSocket mount path. Defaults to
'/__kensington/live'
. Override both this and
connectLive({ url })
together if you need a different path.
heartbeatInterval
Milliseconds between WebSocket pings on the
attach()
path (default
30_000
). Sockets that miss a pong are terminated, firing
onSocketClose
so silent drops release locks and presence in ~one interval.
false
disables. No effect on
bunWebsocket()
.
Handle
Method / property
Description
attach(server)
Mounts the WebSocket handler on a Node HTTP server (requires the
ws
peer dep). Returns the underlying
WebSocketServer
.
bunWebsocket()
Returns the handler config to spread into Bun's
websocket
slot.
close()
Terminates open WebSocket clients, then closes the WSS, then the persistence store.
Call from SIGINT before
httpServer.close()
.
Read, write, enumerate, and remove registry entries from server-side code. Server
set
bypasses
canWrite
.
delete
is registry cleanup only — does NOT notify subscribers. Use
set(name, null)
when subscribers should observe the removal.
policyOf(name)
Resolved persist policy:
true
/
false
/
undefined
. Pair with
list()
to classify entries in diagnostic UIs.
contextFor(ws)
Returns the
ctx
object that
onConnect
returned for a given socket, or
undefined
if the socket is not tracked. Use to correlate
wss.clients
entries with per-socket identity without casting to
any
. Typed as
Ctx | undefined
when
liveServer<Ctx>
is called with an explicit type parameter.
heartbeatInterval
(read-only)
The configured heartbeat cadence in milliseconds, or
false
. Use for SSR state threading so clients can render "last beat N ago"
relative to a known interval.
Outside
renderForHydration
, server-side
liveSignal(initial, name)
returns a long-lived Signal that subscribes to registry updates. Wrap an
effect()
around it to react to client writes and server-side mutations.
Import paths
kensington/live
. Recommended. Exports
liveSignal
,
connectLive
, and
liveServer
. Works in Node, Bun, tests, bundlers, and no-bundler importmap deployments; node-only dependencies are dynamic.
kensington/live/client
(
liveSignal
+
connectLive
) and
kensington/live/server
(
liveServer
) exist for users who want environment boundaries enforced at the import level.
Exports
kensington
import Kensington from 'kensington'; // the class
import { t } from 'kensington'; // shared default instance (new Kensington())
import { signal, computed, effect } from 'kensington';
import { renderForHydration, registerComponents } from 'kensington';
import { isBrowser } from 'kensington'; // true when window is defined
// browser, via CDN
import { t } from 'https://cdn.jsdelivr.net/npm/kensington/dist/kensington.min.js';
kensington/attributes
Every element has a named export containing its allowed-attribute validator object. Useful for extending built-in elements via
createCustomTag
.
import {
divAttributes,
inputAttributes,
formAttributes,
buttonAttributes,
aAttributes,
// ... one export per element
} from 'kensington/attributes';
import { liveSignal, connectLive, liveServer } from 'kensington/live';
Two narrower subpaths exist for environment-bounded imports.
kensington/live/client
exports
liveSignal
and
connectLive
.
kensington/live/server
exports
liveServer
. The unified
kensington/live
path is the documented default.
kensington/devtools
Side-effect import that enables the in-page devtools panel for inspecting signals, effects, and DOM bindings. Drop into a dev-only entry point. The panel mounts itself on first import.
// dev-only entry. Importing for side effects.
import 'kensington/devtools';
kensington/vite
Vite plugin that wires transparent HMR for component files. Accepts an
include
option (glob string, array of globs, or a callback returning either). On save, registered components hot-swap in place. Apply mode is
'serve'
only. Production builds ship the user's original source with no instrumentation. Requires
acorn
and
magic-string
as optional peer deps (lazy-loaded when the plugin runs).
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 spread across roughly ten focused modules
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()
.
Reorders and inserts DOM when a signal value is an array. Matches nodes by an internal key set on the tag instance by
signal.mapWithKey
, so the user-visible DOM stays free of bookkeeping attributes.
Every tag is a plain object that becomes a string or an element on demand. When it becomes an element, a
Lifecycle
wires every signal-driven value into an effect bound to that element via
WeakRef
. A document-wide
MutationObserver
watches for that element's removal and tears the effects down, or pauses them if persist is on.
Construction
String output
DOM output
Lifecycle
Removal
The Pipeline
Kensington has two output modes from one tag instance. The same
ContentTag
object can produce an HTML string via
toString
or a live DOM tree via
toElement
. The pipelines diverge only at the rendering stage.
flowchart TD
A["t.div(attrs, content)"] --> B["createTag closure"]
B --> C["new ContentTag(options)"]
C --> D["collectContent: flatten arrays, drop falsy"]
D --> E{"validationLevel != 'off'?"}
E -- yes --> F["validate(tag)"]
E -- no --> G["tag instance returned"]
F --> G
G --> H{"User calls..."}
H -- "toString()" --> I["renderToString"]
H -- "toElement()" --> J["DOM build + Lifecycle"]
I --> K["HTML string"]
J --> L["Live element"]
t
is an instance of the generated
Kensington
class at
kensington.js
. Every tag method (
t.div
,
t.span
, etc.) is a closure produced by
createTag
. The closure captures the tag name, the allowed-attribute spec map, the
Klass
(which subclass to instantiate), and per-tag options like
namespace
or
contentIsLiteral
.
Stage 1: Tag Construction
The closure returned by createTag accepts several call forms:
t.div(); // no attributes, no content
t.div('hello'); // content only
t.div({ class: 'a' }); // attributes only
t.div({ class: 'a' }, 'hello'); // attributes + content
t.div({ class: 'a' }, [t.p(), t.p()]); // attributes + array content
The closure body disambiguates these forms by inspecting the first argument's prototype. A plain object (
Object.prototype
or
null
prototype) is treated as attributes. Anything else, a tag instance, array, string, number, or Signal, is treated as content.
The createTag closure
At
kensington.js
, each closure instantiates the appropriate tag class with a consistent options object:
const instance = new Klass({
additionalGlobalAttributes: this.additionalGlobalAttributes,
allowedAttributeMap, // built once when createTag was called
attributes,
content,
contentIsLiteral,
encodeContent,
indentationLevel: this.indentationLevel,
logger: this.logger,
namespace,
namespaces: this.namespaces,
tagName,
validationLevel: this.validationLevel,
});
The
allowedAttributeMap
is built once when
createTag
is first called and shared across every invocation of that closure. Validating
t.div(...)
a million times does not rebuild the spec map a million times.
Defined at
content-tag.js
. Recursively flattens nested arrays into a single linear list and drops items that should not render:
function collectContent(items, seen = new Set()) {
const out = [];
for (const c of [].concat(items)) {
if ([undefined, null, '', false, true].includes(c)) {
continue; // false/true arise from conditional patterns: condition && t.span(...)
}
if (Array.isArray(c)) {
if (seen.has(c)) { continue; } // cycle detection
seen.add(c);
out.push(...collectContent(c, seen));
continue;
}
out.push(c);
}
return out;
}
Validation
If validationLevel is 'warn' or 'error', the tag runs
validate()
immediately after construction (see
validate.js
):
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.
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 (isKensingtonSignal(content)) { content = content.get(); }
if (!['string', 'number'].includes(typeof content)) { return false; }
if (content.length > 100) { return false; }
return !LINE_BREAK_TEST_REGEX.test(content);
}
Attribute serialization
attributeString(tag)
calls
attributesStringFromObject
at
attributes.js
. It iterates the attribute array and serializes each pair as
name="value"
, encoding the value through the
he
encoder wrapper. Booleans render as the bare attribute name (
disabled
not
disabled="true"
). Function values cannot be serialized to strings and are silently omitted, with the
handleFunctionValues
callback invoking
showInvalid
at that point rather than at tag creation.
Attribute composition
Before serialization,
attributesArrayFromObject
normalizes the options object into the flat
[name, value]
array that both
toString()
and
toElement()
consume.
A
style
object is stringified to a declaration list. camelCase property names convert to kebab-case, and
null
,
undefined
, and
false
values are omitted.
A
class
array is joined to a single string. Falsy entries drop out.
The
prop
and
on
keys are skipped here. They never enter the HTML attribute pipeline.
Stage 3: DOM Output
tag.toElement(opts)
is the heavy path. It builds a live DOM element, wires every signal-attribute, prop, and content into an effect, registers connect/disconnect callbacks with the DOM tracker, and returns the element ready to be inserted into the document.
flowchart TD
S(["toElement()"]) --> A{"domElement cached?"}
A -- yes --> A1{"isConnected?"}
A1 -- yes --> R1["showInvalid + return cached"]
A1 -- no --> A2{"persist?"}
A2 -- yes --> R2["return cached"]
A2 -- no --> A3{"hasStaleDescendantBindings?"}
A3 -- yes --> A4["clear cache, fall through"]
A3 -- no --> A5{"parentNode != null?"}
A5 -- yes --> R3["showInvalid + return cached"]
A5 -- no --> R4["return cached"]
A -- no --> B["validateContent"]
A4 --> B
B --> C["createElement(NS)"]
C --> D["createLifecycle(element, persist)"]
D --> E["For each attribute"]
E --> E1{"Value type?"}
E1 -- "on*+function" --> E2["addEventListener"]
E1 -- "Signal" --> E3["lifecycle.signalEffect"]
E1 -- "plain" --> E4["setAttribute"]
E2 & E3 & E4 --> F["For each 'on' event"]
F --> F1["addEventListener"]
F1 --> G{"Has props?"}
G -- yes --> G1["For each prop: Signal? signalEffect : assign"]
G -- no --> H["For each content item"]
G1 --> H
H --> H1{"Item type?"}
H1 -- "ContentTag/Literal/Comment" --> H2["recurse toElement, append"]
H1 -- "Signal" --> H3["anchors + signalEffect -> reconcile"]
H1 -- "plain" --> H4["createTextNode"]
H2 & H3 & H4 --> I["lifecycle.finalize"]
I --> J{"hasSignalContent?"}
J -- yes --> K["markContentTracked"]
J -- no --> L["cache domElement"]
K --> L
L --> R3["return element"]
Cache check
If
#domElement
is set, the cache check has four branches:
Already in the DOM (isConnected).
Returning the cached element would silently move the node.
showInvalid
reports it, then the cached element is returned anyway.
Persist mode.
Effects are paused, not stopped. Return the cached element unconditionally.
Disconnected and has stale descendant bindings.
A descendant's effects were stopped on removal. Reusing would return a subtree with dead effects. Drop the cache and fall through to a fresh build.
Disconnected, no stale bindings.
Covers never-mounted elements, static subtrees, elements pre-built before their parent mounts, non-reactive removed elements, and elements in a detached in-memory tree. Return the cached element. For the in-memory tree case (
parentNode !== null
but
!isConnected
),
showInvalid
also reports it since the node will be moved.
if (this.#domElement) {
if (this.#domElement.isConnected) {
showInvalid('toElement() called on a tag instance already in the DOM ...', ...);
return this.#domElement;
}
if (persist) { return this.#domElement; }
if (this.#hasStaleDescendantBindings()) {
this.#domElement = null;
} else {
if (this.#domElement.parentNode !== null) {
showInvalid('toElement() called on a tag instance already in the DOM ...', ...);
}
return this.#domElement;
}
}
Element creation
const element = this.namespace ? document.createElementNS(this.namespace, this.tagName) : document.createElement(this.tagName);
const lifecycle = createLifecycle({ element, persist });
let hasSignalContent = false;
SVG and MathML tags carry their namespace through the
createSvgContentTag
and
createMathTag
factories. For HTML tags, namespace is undefined and
createElement
is used.
Attribute wiring
Iterates the result of
attributeArray()
, a flat list of
[name, value]
pairs after camelCase-to-kebab conversion, nested-namespace expansion, style-object stringification, and class-array joining. For each pair:
Match
Action
onclick
,
oninput
, etc. with a function value
element.addEventListener(name.slice(2), fn)
Signal value
lifecycle.signalEffect(sig, apply, attrName)
Plain value
element.setAttribute(name, value)
The signal-attribute apply function:
lifecycle.signalEffect(attrValue, (el, val) => {
if (val === false || val === null || val === undefined) {
el.removeAttribute(attrName);
} else if (val === true) {
el.setAttribute(attrName, ''); // bare attribute (disabled, checked, etc.)
} else {
el.setAttribute(attrName, String(val));
}
}, attrName);
The effect runs once immediately to set the initial value, then re-runs whenever the signal changes. Inside the effect,
el
is the result of
elementRef.deref()
inside the lifecycle module. If the element has been garbage-collected, the effect self-stops.
Event handlers (the on object)
The
on
attribute attaches multiple event handlers via a single nested object:
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:
A
Signal
can also be rendered without a wrapping element, by calling
signal.toElement()
or
signal.mount(target)
directly. The mechanism lives in
signal-render.js
.
Deliberately simple. Unlike signal-as-content (a child of a real tag), this path does NOT route through
reconcile
. Each change clears all sibling nodes between the anchors and renders the new value fresh. Keyed-list matching, preserve-state restoration, and bidirectional diffing are unnecessary for a standalone signal whose value is typically a single tag (the "swap between two views" pattern). Keeping
reconcile
and
preserve-state
out of the slim build's hot path is the reason. If you need keyed reconciliation around a signal, wrap it in a tag:
t.div([signal.mapWithKey('id', renderRow)])
This is what makes
signal.transform(v => v ? tagA : tagB)
usable as a function return value. The returned
ReadonlySignal<ContentTag>
is itself a renderable thing. No wrapping
t.div([signal])
is required just to satisfy the
ContentTag
return type at the call site.
connectCallbacks.
User-registered via
addConnectedCallback
. Fire on every insertion when persist is true. Once otherwise.
disconnectCallbacks.
User-registered via
addDisconnectedCallback
. Fire on every removal.
onCleared.
Internal. Resets
#domElement
to null after removal so
getDomElement()
returns null.
onReconnect.
Internal. Restores
#domElement
to the live element on re-insertion under persist mode.
Signal Anatomy
Before tracing the lifecycle module, here is how a Signal works. The full implementation is at
signal.js
.
Subscription via .get()
A Signal's subscribers are kept in a private
Set
on the instance. The mechanism that wires up a subscription is the module-scoped
currentEffect
reference, set during an
effect()
or
computed()
run:
get() {
if (currentEffect !== null && !this.#subscribers.has(currentEffect)) {
this.#subscribers.add(currentEffect);
currentEffect._reads.add(this);
currentEffect._cleanups.push(this); // push the Signal, not a per-sub closure
}
return this.#value;
}
Calling
.get()
outside an effect or computed registers no subscription. It's just a read.
Calling
.get()
twice in the same effect is idempotent. The
has(currentEffect)
check prevents duplicates.
The Signal itself is pushed to the effect's
_cleanups
array. On re-run or stop,
track()
walks the array and calls
sig._unsubscribeFromRun(run)
on each entry. Pushing the Signal instead of a per-subscription closure removes one closure allocation per signal read, which adds up to many thousands per render of a typical list.
Writes and the microtask flush
.set(next)
at
signal.js
compares via
Object.is
and bails on equality. Otherwise it updates the value and notifies subscribers:
sequenceDiagram
participant U as User code
participant S as Signal
participant Q as pending Set
participant Mt as queueMicrotask
participant E as effect.run
U->>S: .set(next)
S->>S: Object.is(next, current)?
alt equal
S-->>U: return early
else changed
S->>S: value updated
loop each subscriber
alt subscriber is effect
S->>Q: scheduleRun(fn)
S->>Mt: queueMicrotask(flush)
else subscriber is computed.update
S->>E: update() synchronously
end
end
S-->>U: return
Mt->>Q: flush()
loop each pending fn
Q->>E: run()
end
end
Effects are batched. Multiple
.set()
calls in the same synchronous turn coalesce into a single re-run per effect because
pending
is a
Set
.
Computed updates run synchronously. This is intentional. A computed reading
a.get() + b.get()
must always be consistent with the latest values of
a
and
b
.
effect()
effect(fn)
at
signal.js
guards against misuse before delegating to an internal
createEffect(fn)
helper. If called inside a running effect or computed body, it fires a throttled error because a new effect is started on every re-run without stopping the old one.
export function effect(fn) {
if (inComputedFn) {
throttledError('effect-in-computed', 'kensington: effect() called inside a computed or transform callback...');
} else if (currentEffect !== null) {
throttledError('effect-in-effect', 'kensington: effect() called inside an effect callback...');
}
return createEffect(fn);
}
createEffect(fn)
is the shared implementation:
function createEffect(fn, isInternal = false) {
if (isSSRMode()) {
return { pause() {}, resume() {}, stop() {} };
}
let paused = false;
let destroyed = false;
function run() {
if (paused) { return; }
track(run, fn);
}
run._cleanups = [];
run._isEffect = true;
run._isInternal = isInternal;
run();
return {
pause() {
paused = true;
pending.delete(run);
for (const cleanup of run._cleanups) { cleanup(); }
run._cleanups = [];
},
resume() {
if (destroyed) { return; }
paused = false;
run();
},
stop() {
this.pause();
destroyed = true;
},
};
}
The three returned methods give the caller control:
pause
Drains
_cleanups
(unsubscribing from every signal) and removes itself from
pending
. The effect won't re-run until
resume()
is called.
resume
Calls
run()
immediately, re-tracking subscriptions to every signal read inside it. No-op if
destroyed
is true.
stop()
calls
pause()
and sets
destroyed = true
, making
resume()
a permanent no-op. This is the teardown path when an element is removed without persist mode.
_internalEffect(fn)
is identical to
effect(fn)
but passes
isInternal = true
to
createEffect
, which sets
run._isInternal = true
on the effect's run function. That flag suppresses the
out-of-scope-reactive-reference
warning when the effect subscribes to a keyed primitive, because DOM-binding effects are tied to the DOM and not a genuine user-land capture. Devtools categorises these effects as DOM bindings via
markNextEffectAsBinding
. Callers include
lifecycle.signalEffect
(via
_bindingEffect
),
LiteralTag
,
CommentTag
, and
map-with-key.js
(for the keep-alive subscriber that holds the per-key inner computed awake).
_bindingEffect
_bindingEffect(sig, fn)
is a single-signal fast-path effect used by every lifecycle attribute, content, prop, and style binding. It bypasses
track()
entirely. No
_cleanups
iteration, no
_readsSet
, no
currentEffect
dance. Subscribe and unsubscribe go through
sig._bindingSubscribe(run)
and
sig._bindingUnsubscribe(run)
. The single signal it observes drives one DOM property, so collapsing the round-trip removes ~20k subscribe / resubscribe pairs in benchmarks that fire many updates.
_internalComputed
_internalComputed(fn)
is the library-internal pair of
_internalEffect
. It clears
inComputedFn
and
currentEffect
around the
computed(fn)
call so the computed-in-computed entry warning (intended for user mistakes) does not fire when the library is intentionally creating an inner computed. Used by
map-with-key.js
for the per-key reactive path.
computed()
computed(fn)
at
signal.js
creates a Signal whose value is derived from other signals. Updates are synchronous (unlike effects).
Under
isSSRMode()
(the counter lives in
ssr.js
),
fn()
runs once with no
currentEffect
set, so source
.get()
calls do not register a subscription. The returned Signal carries the snapshot value and never updates. This prevents per-request computed calls from leaking subscribers onto module-level signals that outlive the request.
Auto-dispose: when a computed's last subscriber is removed, a sleep callback unsubscribes from all sources and freezes the value. On the next
.get()
inside a reactive context, a wake callback re-runs
fn()
and re-subscribes to sources. This means an explicit
.stop()
call is rarely needed. When the parent effect re-runs and clears its subscriptions, the inner computed auto-sleeps and releases its source subscriptions automatically.
Keyed signals
A second argument to
signal()
turns it into a keyed signal scoped to the innermost running
computed
. The implementation in
signal.js
tracks the active computed in a module-level
currentComputed
variable, and stores a per-computed registry in a
keyedRegistries
WeakMap:
export function signal(initial, key) {
if (key !== undefined && currentComputed !== null) {
const owner = currentComputed;
let registry = keyedRegistries.get(owner);
if (registry === undefined) {
registry = { signals: new Map(), accessed: new Set() };
keyedRegistries.set(owner, registry);
}
if (registry.accessed.has(key)) {
throttledError('duplicate-keyed-signal', /* ... */);
}
registry.accessed.add(key);
const existing = registry.signals.get(key);
if (existing !== undefined) { return existing; }
const sig = new Signal(initial);
registry.signals.set(key, sig);
return sig;
}
return new Signal(initial);
}
Each computed run clears its
accessed
set before invoking the user's function. After the run completes, any key in the registry that wasn't accessed is stopped and removed:
// Sweep keyed signals that weren't touched this run.
if (registry !== undefined) {
for (const [k, sig] of registry.signals) {
if (!registry.accessed.has(k)) {
sig.stop();
registry.signals.delete(k);
}
}
}
This handles list mappings naturally.
signal(false, item.id)
inside
items.get().map(...)
returns the same instance for the same item id across renders. When an item leaves the list, its key is never accessed, so the signal is stopped and the entry removed in the same render cycle.
Reusing the same cached tag instance across renders keeps the reconciler in the cheap bidirectional path.
tagNeedsRebuild
returns false because the tag still backs its live DOM node, so prefix and suffix matches advance with no DOM mutation. A fresh tag for the same key (when
mapFn
touches a signal that changed) triggers
rebuildNode
plus
preserve-state.js
for that one row. There is no snapshot fast path and no in-place attribute patching.
The registry uses a plain
Map
for the per-key lookup and a plain
Set
for accessed-key tracking, so the key can be any value with
SameValueZero
identity. String, number, symbol, or object reference. Object keys work when the same reference is passed across outer re-runs (e.g. mutating items in place). Immutable update patterns that clone the item object on every change break the match and lose state, so
item.id
is the safer default. The TypeScript signature reflects this with the exported alias
SignalKey = string | number | object | symbol
.
Keyed computeds
A second argument to
computed()
works the same way as for
signal()
: it scopes the inner computed to the innermost running
computed
and returns the same instance for the same key across outer re-runs. The implementation stores a separate
keyedComputedRegistries
WeakMap alongside
keyedRegistries
.
The fn closure cannot be stored in a
Signal
directly because
Signal.set()
treats function-type arguments as updater functions. Instead, the registry entry holds a mutable
fnWrapper
object and a plain numeric
versionSig
counter. The inner computed closes over both:
On each outer re-run, if the fn reference changed, the wrapper is updated and the version counter is incremented. The increment triggers the inner computed to re-run with the new fn:
If the inner computed is sleeping when
versionSig.set()
is called, there are no subscribers to notify. The increment is a no-op at that moment. When the outer then calls
inner.get()
, the wake path re-runs the inner's
update()
function, which reads
fnWrapper.fn
. Already updated. And produces the correct value.
A
keyedScopeOwners
WeakMap records the owner outer computed for each keyed inner signal and computed.
Signal.get()
checks this map when adding a subscriber: if the subscriber is a user-land effect or computed that is not the owner, a throttled warning fires. DOM-binding effects created via
_internalEffect()
carry an
_isInternal
flag and are skipped. Their lifetime is tied to a DOM node that is part of the owner's own render cycle, so a key drop and the corresponding DOM removal happen together. The warning only fires for true escapes: user code capturing a keyed instance in a long-lived effect or computed.
Signal.prototype.transform
is a thin wrapper that forwards its optional key to
computed
, so
signal.transform(fn, key)
uses the same keyed-computed registry and lifecycle.
createLifecycle({ element, persist })
is a closure factory. Each call to
toElement
creates one. The returned object exposes two methods:
signalEffect(sig, apply, label)
and
finalize({...})
.
This module is the only place that decides whether to pause or stop an effect on removal.
Internal state
export function createLifecycle({ element, persist }) {
const effects = []; // the bound effects themselves
const devIds = []; // effect IDs for devtools
const elementRef = new WeakRef(element); // shared across every signalEffect
}
Effects are stored directly in the
effects
array. The stop chain registered by
finalize
iterates that array and dispatches once based on the captured
persist
flag, rather than allocating a
() => persist ? eff.pause() : eff.stop()
closure per signal effect.
signalEffect
signalEffect(sig, apply, label) {
markNextEffectAsBinding(label); // devtools: tag as DOM binding
const eff = _bindingEffect(sig, val => {
const el = elementRef.deref();
if (!el) { eff.stop(); return; } // element collected; self-stop
apply(el, val);
});
notifyEffectElement(eff._devId, element); // devtools: link effect to element
effects.push(eff);
return eff;
}
The effect runs once immediately when created, applying the initial signal value. On subsequent runs, it dereferences the WeakRef. If the element has been garbage-collected, the effect self-stops. No zombie subscriptions.
_bindingEffect
is a lightweight effect that subscribes to exactly one signal. It bypasses
track()
entirely. No
_cleanups
iteration, no
_reads
Set, no
currentEffect
dance. It just calls
fn(sig.value)
on each notification. Lifecycle bindings always read exactly one signal, so this fast path collapses the per-run subscribe/resubscribe pair that the general effect machinery does on each fire.
finalize
finalize
registers the stop chain and (if needed) the connect callback with dom-tracker. Two branches:
persist: false (default)
On removal, every effect's stop() is called. Permanent teardown. Disconnect callbacks fire once. Connect callback fires once on first insertion only.
persist: true
On removal, every effect's pause() is called. The stop chain rebuilds for the next cycle via reFireAndRegister. On reconnect, every effect's resume() is called and the connect callback re-fires.
The disconnect chain
function registerDisconnectChain() {
trackForStop(element, () => {
if (persist) {
for (let i = 0; i < effects.length; i++) { effects[i].pause(); }
} else {
for (let i = 0; i < effects.length; i++) { effects[i].stop(); }
}
}, devIds);
if (onCleared) { addOnStop(element, onCleared); }
for (const fn of disconnectCallbacks) {
addOnStop(element, () => fn.call(element, element));
}
}
trackForStop
registers the first link in the chain. The branch on
persist
is resolved once per removal, then a tight loop dispatches the right method on every effect; we never allocate a per-effect
pauseOrStop
closure.
addOnStop
appends to the chain: first
onCleared
(which resets the tag's
#domElement
cache), then each user-registered disconnect callback.
The persist rebuild
When persist is true, the chain rebuilds every cycle so disconnect callbacks fire on every removal, not just the first:
if (persist) {
const reFireAndRegister = () => {
trackForStop(element, () => {});
if (onCleared) { addOnStop(element, onCleared); }
for (const fn of disconnectCallbacks) {
addOnStop(element, () => fn.call(element, element));
}
addOnStop(element, reFireAndRegister); // self-perpetuates
};
addOnStop(element, reFireAndRegister);
}
The connect path
const needsConnect = persist || connectCallbacks.length > 0;
if (needsConnect) {
let initialConnect = true;
trackForConnect(element, () => {
if (initialConnect) {
initialConnect = false;
} else {
if (onReconnect) { onReconnect(); }
if (persist) {
for (const eff of effects) {
eff.resume();
addOnStop(element, () => eff.pause());
}
}
}
for (const fn of connectCallbacks) { fn.call(element, element); }
}, persist);
}
The shared callback-fire loop runs on both first connection and reconnection.
Only the reconnect-specific work (
onReconnect
, resume + re-pause wiring) is gated on the else branch.
A shared
MutationObserver
watches
document.documentElement
for any subtree mutation. When tracked elements are added or removed, it fires registered callbacks. This is what closes the loop between "element removed from the DOM" and "effects stop, signals unsubscribe."
The entries map
const entries = new WeakMap();
const contentTracked = new WeakSet();
let hasAnyTracked = false; // one-shot latch: observer short-circuit when nothing was ever tracked
entries
is a WeakMap keyed by element. Each entry holds a stop function, an optional connect function, and a persist flag.
hasAnyTracked
is a single boolean. The observer reads it to skip all per-record work in the common case where nothing in the document is tracked yet.
The observer
function buildObserver() {
if (observer !== null) { return; }
observer = new MutationObserver(records => {
if (!hasAnyTracked) { return; }
for (const record of records) {
for (const node of record.removedNodes) { if (!node.isConnected) { stopRemoved(node); } }
for (const node of record.addedNodes) { fireConnected(node); }
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
Built lazily on the first
trackForStop
or
trackForConnect
call. There is exactly one for the whole document. The
hasAnyTracked
latch means mutation records are skipped without any per-record work when nothing has ever been tracked.
The
stopRemoved
call is guarded by
if (!node.isConnected)
. A node removed and immediately reinserted in the same mutation batch will be connected again when the observer fires, so its effects must not be stopped.
The visit helper
visit(node, fn)
handles two cases for a mutation record's node: the node itself might be tracked, or it might be an ancestor of one or more tracked elements. The walk uses
document.createTreeWalker
so the cost scales with the subtree of the removed node, not with the total number of tracked elements:
function visit(node, fn) {
const own = entries.get(node);
if (own !== undefined) { fn(node, own); }
if (node.nodeType !== 1) { return; }
const skipComments = own !== undefined;
const walker = document.createTreeWalker(node, SHOW_ELEMENT_AND_COMMENT);
for (let el = walker.nextNode(); el !== null; el = walker.nextNode()) {
if (skipComments && el.nodeType !== 1) { continue; } // protect LiteralTag/CommentTag anchors
const entry = entries.get(el);
if (entry !== undefined) { fn(el, entry); }
}
}
Used by the reconciler's clear path. Walks an entire sibling range with a single TreeWalker. Building one walker is dramatically cheaper than building 1000 walkers when
rowsSignal.set([])
clears a list with a thousand reactive rows. The shared
stopOne(element, entry)
helper handles the "clear entry then call stop" sequence used by every dom-tracker teardown path.
API surface
Export
Purpose
trackForStop(el, fn, devIds)
Register the initial stop function and associated devtools effect IDs.
trackForConnect(el, fn, persist)
Register the connect callback. persist controls re-fire and entry survival after removal.
addOnStop(el, fn)
Append to the stop chain. No-op if stop is not set.
markContentTracked(el)
Flag an element as owning signal-content anchors.
isTracked(el)
Does this element have an active stop registration?
isContentTracked(el)
Was this element flagged via markContentTracked?
stopTracked(el)
Force synchronous teardown. Used by the reconciler for discarded fresh nodes.
stopRemoved(node)
Called by the MutationObserver.
Calls visit() to find and stop all tracked entries for node or its descendants.
stopRangeBetween(first, end)
Stop every tracked entry in a sibling range with one TreeWalker pass.
Used by reconcile's clear shortcut.
Removal Flow
An element is removed when something calls
element.remove()
,
parent.removeChild(element)
,
parent.replaceChildren(...)
, etc. The browser fires a mutation record. The shared MutationObserver picks it up.
sequenceDiagram
participant U as User code
participant B as Browser
participant Mo as MutationObserver
participant T as DOM tracker
participant L as Lifecycle stops
participant S as Signals
U->>B: element.remove()
B->>Mo: MutationRecord (removedNodes)
Mo->>T: stopRemoved(node)
T->>T: visit(node, fn)
Note over T: For node itself AND any tracked descendants
T->>T: clearStop(entry, el)
T->>L: stop()
Note over L: pauseOrStop for each effect, then onCleared, then disconnectCallbacks
L->>S: eff.stop() or eff.pause()
Note over S: Drains _cleanups, removes from subscribers
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.
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.
Every signal-content update calls
reconcile
at
reconcile.js
. The function reorders, inserts, removes, and rebuilds DOM nodes between a pair of comment anchors set up at element construction. Non-arrays are wrapped as
[val]
before passing in, so the algorithm only handles the array case.
The algorithm is Vue 2 / Inferno style bidirectional reconciliation. Four cheap cases handle the common shapes (no change, prefix match, suffix match, swap, head-to-tail move) with at most one DOM mutation each. Anything that falls through hits a keymap walk from right to left for the mixed middle.
The incoming array is normalized first.
flattenIfNeeded(items)
only allocates a flat buffer when the input actually contains a nested array. The common already-flat case returns the input unchanged.
filterRenderable(items)
strips non-renderable items (
null
,
undefined
,
false
,
true
, and empty string, the same set
isRenderableItem
rejects) so the bidirectional pass can index by position. The hot path returns the input array directly with no allocation.
itemToNode(item)
calls
item.getDomElement?.() ?? item.toElement()
so a per-key cached tag returns its already-built node.
Keys and node lookup
Keys live on the tag instance, not on the rendered element.
signal.mapWithKey(keyOrProp, mapFn)
stamps each tag returned from
mapFn
with a Kensington-internal property (
KENSINGTON_KEY
equals
'_kensingtonKey'
). The reconciler reads that property via
itemKey(item)
and pairs each key with its live DOM node through the
nodeKeysWeakMap
, populated when a fresh node is inserted. The rendered HTML stays free of internal bookkeeping attributes.
function itemKey(item) {
if (item === null || typeof item !== 'object') { return null; }
const key = item[KENSINGTON_KEY];
if (key !== undefined) { return key; }
// Stable tag instances passed directly (without mapWithKey) get the tag itself
// as an implicit key. The reconciler can then recognize the same tag across
// renders without requiring the user to thread an explicit key through.
if (item._isKensingtonTag === true) { return item; }
return null;
}
Stable tag instances passed directly (not via
mapWithKey
) get the tag instance itself as an implicit key, so reusing the same
t.div(...)
reference across renders is enough to keep its DOM node. Items that are neither a stamped tag nor a Kensington tag (plain strings, numbers, plain objects) are unkeyed and always build a fresh DOM node.
Clear fast path
Before the main loop, an empty
newItems
takes a dedicated fast path. A single
stopRangeBetween(firstChild, endAnchor)
walks every tracked descendant in one
TreeWalker
pass and stops its effects. Then
Range.deleteContents()
removes the DOM in one operation.
if (items.length === 0 && startAnchor.nextSibling !== endAnchor) {
stopRangeBetween(startAnchor.nextSibling, endAnchor);
const range = document.createRange();
range.setStartAfter(startAnchor);
range.setEndBefore(endAnchor);
range.deleteContents();
return;
}
Bidirectional matching
After the clear shortcut, the reconciler snapshots the current children into an
oldChildren
array and runs four pointer indices:
oldStart
,
oldEnd
,
newStart
, and
newEnd
. On every iteration of the outer loop, it tries four cheap matches in order and falls through to the keymap path only if none apply.
while (oldStart <= oldEnd && newStart <= newEnd) {
const oldStartNode = oldChildren[oldStart];
const oldEndNode = oldChildren[oldEnd];
const oldStartKey = nodeKeys.get(oldStartNode);
const oldEndKey = nodeKeys.get(oldEndNode);
const newStartKey = itemKey(items[newStart]);
const newEndKey = itemKey(items[newEnd]);
if (oldStartKey === newStartKey && oldStartKey !== undefined) {
// Prefix match. No DOM op (or one rebuild if the tag is stale).
oldStart++; newStart++;
} else if (oldEndKey === newEndKey && oldEndKey !== undefined) {
// Suffix match. No DOM op.
oldEnd--; newEnd--;
} else if (oldStartKey === newEndKey && oldStartKey !== undefined) {
// Head moved to tail. One insertBefore.
parent.insertBefore(node, oldEndNode.nextSibling);
oldStart++; newEnd--;
} else if (oldEndKey === newStartKey && oldEndKey !== undefined) {
// Tail moved to head. One insertBefore. The js-framework-benchmark swap test lives here.
parent.insertBefore(node, oldStartNode);
oldEnd--; newStart++;
} else {
break; // Fall through to the keymap path.
}
}
Prefix match.
Old and new agree at the head. Advance both
Start
indices. No DOM mutation.
Suffix match.
Old and new agree at the tail. Retreat both
End
indices. No DOM mutation.
Head to tail.
The head of the old list now lives at the tail of the new list. One
insertBefore
moves it.
oldStart++
,
newEnd--
.
Tail to head.
The tail of the old list now lives at the head of the new list. One
insertBefore
moves it.
oldEnd--
,
newStart++
. This is the path the js-framework-benchmark swap row test exercises.
Rebuild on stale tag
A keyed match resolves to either the same cached tag (DOM reused as-is) or a fresh tag instance for that key. The fresh case happens when
mapWithKey
's per-key inner computed re-ran because
mapFn
touched a signal that changed.
tagNeedsRebuild(item, node)
detects it by asking the tag for the DOM it currently backs.
When the tag is stale,
rebuildNode
captures user-visible state from the old node via
preserve-state.js
(
captureState
), builds the fresh DOM via
item.toElement()
, inserts the fresh node before the old, removes the old node (which triggers
dom-tracker
to stop the old effects), and restores state onto the fresh subtree.
Focus and selection,
scrollTop
and
scrollLeft
,
input.value
,
checked
,
indeterminate
,
<select>
value, and
<details>
/
<dialog>
open all survive the swap. This path is dormant for static
mapFn
implementations (the common case).
mapWithKey
probes
mapFn
on first sight of a key, and if it touched nothing reactive the cached tag is a plain entry that never needs rebuild.
Main loop and slow path
When the bidirectional loop ends, three tails remain to handle. The trailing fence (the first
oldChildren
node after
oldEnd
, or
endAnchor
if none survived) is the
insertBefore
reference for new inserts.
Pure insert tail.
If
oldStart > oldEnd
, every remaining new item is appended before the trailing fence with one
insertBefore
each. Used when a list grows.
Pure remove tail.
If
newStart > newEnd
, every remaining old child is removed with
.remove()
plus
stopRemoved
to stop its tracked effects. Used when a list shrinks.
Mixed middle.
Otherwise, build a keymap over the remaining
oldChildren
range and walk the new range from right to left, so the
insertBefore
reference is always the previous iteration's node. Matched keys reuse their old DOM (or rebuild via
rebuildNode
if the tag is stale), unmatched keys build fresh DOM via
itemToNode
. Old nodes that no new item claimed are removed at the end of the loop.
signal.mapWithKey(keyOrProp, mapFn)
is the keyed list mapper that feeds stable tag instances to the
reconciler
. It is attached to the prototype as
Signal.prototype.mapWithKey = mapWithKey
and returns an
_internalComputed
wrapper, not a plain
computed
. The internal form keeps a nested
mapWithKey
(the documented recursive-tree pattern) from tripping the computed-in-computed warning, and keeps the wrapper's own reads of user-keyed signals inside each row from tripping the out-of-scope warning.
The probe
The first time a key is seen,
buildEntry(item, mapFn)
runs
mapFn
under
_runMapWithKeyProbe
(
signal.js
). The probe swaps
currentEffect
to a throwaway probe object, clears
currentComputed
and
inComputedFn
, and runs
mapFn
once to discover whether the row is reactive.
const { result, needsReactive } = _runMapWithKeyProbe(() => mapFn(item));
// needsReactive is true if mapFn read any signal (probe._cleanups non-empty)
// or created any signal(initial, key) / computed(fn, key).
needsReactive
is true when
mapFn
read at least one signal (the probe collected a subscription) or created a keyed primitive. On a positive result the probe's subscriptions are unwound before returning, so the probe never leaves a dangling subscriber.
Static versus reactive entries
The probe result decides the shape of the cache entry. The cache is a plain
Map
keyed by the user key.
Static.
When
needsReactive
is false, the entry is
{ tag, inner: null, keepAwake: null }
. The same tag is returned on every later render. The cost is identical to a plain
Map
lookup.
Reactive.
When
needsReactive
is true, the entry is upgraded to
{ tag: null, inner, keepAwake }
where
inner = _internalComputed(() => mapFn(item))
and
keepAwake = _internalEffect(() => { inner.get(); })
.
When a tracked signal changes, the inner re-emits a fresh tag. The reconciler's
tagNeedsRebuild
sees
item.getDomElement() !== oldNode
and
rebuildNode
swaps the row's DOM in place via
preserve-state.js
.
The render pass and sweeping
On each render the outer wrapper iterates the source array, looks up each key, and returns the current tag with
entry.tag === null ? entry.inner.get() : entry.tag
. Each tag is stamped with its key via
stampKey(tag, key)
, which writes the internal
KENSINGTON_KEY
property (
'_kensingtonKey'
) that the reconciler reads back. Keys whose items disappeared from the array are swept. Their
inner
and
keepAwake
are stopped so per-row signals and computeds tear down.
Misuse warnings
Duplicate keys.
Two items resolving to the same key in one render fire
throttledError('mapwithkey-duplicate-key', ...)
and the first item wins. The duplicate is skipped, so each key always maps to exactly one cached tag.
Called inside a reactive context.
Calling
mapWithKey
inside a
computed
or
effect
fires
throttledWarn('mapwithkey-in-reactive', ...)
via
_isInReactiveContext()
, because the whole per-key registry would rebuild on every outer re-run.
On the server (or any environment without a real DOM), reactive subscriptions must not be created. They would have nothing to update and no cleanup path, so they would leak immediately. Both
effect()
and
computed()
consult
isSSRMode()
from
ssr.js
. The SSR mode counter,
_enterSSRMode
,
_exitSSRMode
, and
isSSRMode
all live in that module.
The SSR bypass
function createEffect(fn, isInternal = false) {
if (isSSRMode()) {
return { pause() {}, resume() {}, stop() {} }; // no-op stub
}
// ... normal path ...
}
export function computed(fn) {
if (isSSRMode()) {
const s = new Signal(fn()); // value snapshot, no subscriptions
derivedSignals.add(s);
return s;
}
// ... normal path ...
}
Inside an SSR call,
effect()
returns a no-op stub (the
isSSRMode()
check lives in
createEffect
, which
effect()
calls) and
computed()
returns a frozen-value Signal. No subscriptions are created in either case.
tag.toString()
still reads signal values via
.get()
(which works fine without a current effect) and produces a static HTML snapshot.
renderForHydration
renderForHydration(fn, state, name?, options?)
in
hydration.js
wraps a component for isomorphic rendering. On the server, it calls
_enterSSRMode()
, invokes
fn(state, context)
(where
context
comes from
options.context
), calls
toString()
, embeds the resulting HTML alongside a JSON state block, and calls
_exitSSRMode()
in finally. The caller is responsible for inserting the resulting HTML into the page.
On the client,
renderForHydration
produces a placeholder element that
registerComponents
later replaces with the live DOM version.
context
is the non-serialized second argument every component receives. The server passes its own context to
renderForHydration
; the client passes its own to
registerComponents
. The framework stores them in a per-name
contextRegistry
Map alongside
componentRegistry
and forwards them on every invocation, including HMR hot-swaps. The
state
arg remains the only serialized payload.
registerComponents + the JSON block
On the client,
registerComponents({ name: fn }, { context })
reads the JSON block embedded by the server render, looks up each registered component by name, and runs
fn(state, context)
to produce a fresh tag instance. That instance's
toElement()
creates the live DOM tree with signal effects, which then replaces the SSR-rendered HTML in the document. The optional
context
is stored per component name and reused for any later HMR re-render.
This is "remove and replace" hydration, not "reuse and attach." The SSR HTML serves time-to-first-paint. The live version takes over once JS is ready.
Hydration scopes
When the client hydrates a component, it does so inside a hydration scope from
hydration-scope.js
. The scope is a
Map
keyed by a per-mount id, holding
{ signals: Map, computeds: Map }
keyed by the user-supplied keys.
hydrateComponent
enters a scope keyed by the SSR mount id, stamps the live nodes with
data-k-mount-target
, and records the instance. The HMR path (
covered below
) uses the same machinery so hot swaps preserve state.
Inside an active scope,
signal(initial, key)
(when
currentComputed === null
) and
computed(fn, key)
look up the per-scope map by key and reuse the existing instance, so user-visible values persist across a re-render of the same mount. New signals are created with
suppressReactiveCheck
so the "signal inside computed" warning does not fire. The read accessor is
getCurrentHydrationScope()
.
The
kensington/vite
subpath ships a small Vite plugin that wires component HMR without asking the user to add any code in the component itself. The plugin parses each matched source file to an AST (
acorn
+
magic-string
, both declared as optional peer dependencies and loaded lazily), wraps each component export with
__kInstrument(name, fn)
, and appends an
import.meta.hot.accept
block that calls
hmrReplaceComponent
on save. Production builds skip the transform entirely (
apply: 'serve'
).
include
accepts a glob, an array of globs, or a callback
(server) => glob | globs | null
. The callback form lets adapters like
kensington-dev-server
source the glob from runtime state that is not known at config time. Internally both forms are normalised to a callback.
export default function(...) {}
(anonymous; name is the file basename)
export default () => ...
(name is the file basename)
export default NAME
(re-export of a local declaration)
export { NAME, NAME2, ... }
(specifier list)
__kInstrument
The runtime counterpart to the AST rewrite.
hydration.js
exports
__kInstrument(name, fn)
as a thin wrapper that:
Allocates a fresh mount id and enters a hydration scope (
_enterHydrationScope(mountId)
in
hydration-scope.js
). Keyed calls like
signal(initial, key)
and
computed(fn, key)
inside the component body look up the per-scope registry and reuse the existing instance if one exists.
Calls the original
fn(state)
and intercepts the returned tag's
toElement
method so the live element is stamped with
data-k-mount-target=<mountId>
and recorded in the per-name
liveInstances
map.
Exposes the original function via the
__kFn
property on the wrapper so the Vite plugin's accept handler can read it back unwrapped.
SSR (
isSSRMode()
) and re-entrant calls (
_inHydrationScope()
) skip instrumentation entirely. The wrapper steps aside and just calls the original
fn
. This keeps server-side renders untouched and avoids double-bookkeeping when
hydrateComponent
itself owns the scope.
hmrReplaceComponent
On every save, the appended HMR accept block calls
hmrReplaceComponent(name, mod.<access>.__kFn)
. The function walks the per-name
liveInstances
set and, for each live instance, performs the swap in place:
for (const inst of [...set]) {
// 1. Detached? Drop and dispose its hydration scope.
if (!inst.mountNodes[0]?.isConnected) {
_disposeHydrationScope(inst.mountId);
dropInstance(name, inst);
continue;
}
// 2. Capture user-visible DOM state (focus, selection, scroll,
// input value, checked, indeterminate, <select> value, ...)
const captured = captureState(inst.mountNodes[0]);
// 3. Re-render inside the SAME hydration scope.
// Keyed signal/computed instances persist; their values survive.
_enterHydrationScope(inst.mountId);
const result = newFn(inst.state);
_exitHydrationScope();
// 4. Replace nodes in place. dom-tracker stops effects on the
// discarded DOM via the MutationObserver, automatically.
inst.mountNodes[0].replaceWith(...result.map(el => el.toElement()));
// 5. Restore the captured state onto the fresh subtree.
restoreState(newNodes[0], captured);
}
Hydration scopes vs. computed-keyed registries
Both mechanisms make
signal(initial, key)
and
computed(fn, key)
return the same instance across re-runs of an enclosing scope. They differ in lifetime:
Keyed registries inside a computed.
Sweep unaccessed keys at the end of every run. An item removed from a list takes its keyed signals with it, automatically.
Hydration scopes.
Do NOT sweep. Stability comes from the mount id, not from access tracking. A scope is disposed only when the mount is removed via
_disposeHydrationScope
, at which point every signal and computed in it is stopped. Re-rendering a component during a hot-swap intentionally keeps every keyed signal alive even if the new module doesn't read it.
SSR + HMR parity
SSR-hydrated components participate in HMR alongside client-only ones. After
registerComponents
runs
hydrateComponent
on an SSR mount marker, the live nodes are stamped with
data-k-mount-target=<mountId>
and recorded in
liveInstances
with the same shape as client-only mounts.
hmrReplaceComponent
walks both kinds uniformly, so an edit to a component file hot-swaps every instance regardless of how it was mounted.
The
kensington/live
subpath ships a small server/client runtime that lets one named primitive synchronize across browsers. Three public entry points all reachable from the unified path:
liveSignal(initial, name, options?)
for shared component code,
connectLive(opts?)
for the client, and
liveServer(opts?)
for the server. Two node-only peer deps (
ws
,
better-sqlite3
) are loaded lazily so the unified path works in browser bundles without pulling them in.
Transport registry. state.js
state.js
owns the per-process current transport reference and the public
liveSignal()
factory. Both
client.js
and
server.js
install themselves via
_registerTransport(transport)
. The factory reads
currentTransport
at call time and delegates to
transport.getOrCreateSignal(name, initial, opts)
. With no transport registered, it returns a placeholder
_signal(initial)
tagged with
_isLivePlaceholder
and adds an entry to a module-level pending set. Pre-upgrade reads and writes both go through the default
Signal.prototype
behavior, updating only the placeholder's local value.
When
_registerTransport(transport)
runs, it drains the pending set and walks each placeholder through
upgradePlaceholder(rec, transport)
, which looks up the registry-backed signal via
transport.getOrCreateSignal(name, placeholder.value, opts)
(so any pre-upgrade local write seeds the registry entry when the name is fresh), installs an internal-effect mirror that pipes
real → placeholder
via
_setFromRemote
, and rewires
placeholder.set
and
placeholder.stop
to forward to
real
. The user-held reference is preserved across upgrade. If the registry already holds a value for the name, the mirror's first run overwrites the placeholder with the authoritative value and the pre-upgrade local write is silently lost.
validateCanWrite(value)
inside the factory rejects invalid
options.canWrite
values eagerly (throws a
TypeError
at the call site rather than waiting for the server to reject the write). The valid forms are
'any'
,
'server-only'
, or a function.
Wire protocol. protocol.js
protocol.js
is the single source of truth for message types and the encode/decode pair.
Ordering is Lamport last-write-wins; the server is the sole authority for lamport assignment. Direct
.set(value)
writes omit both
ifLamport
and
opId
; the server applies them unconditionally subject to
canWrite
. CAS writes (
.set(fn)
) carry both: the server applies only when its current lamport for the name equals
ifLamport
.
opId
correlates the response (
set-ok
or
set-fail
) back to the originating client.
subscribe
carries
persist: true
only when the client explicitly opted in; the field is omitted otherwise so the server can distinguish a positive declaration from a default-follow.
Client transport. client.js
client.js
exports
connectLive(opts)
which constructs a
ClientTransport
, registers it via
_registerTransport
, and calls
transport.connect()
before returning. One transport instance per tab, multiplexed across every
liveSignal
.
Instance state
signals
. Map<name, Signal>. Backs the auto-resubscribe-on-reconnect loop.
initialValues
. Map<name, T>. First-call initial for duplicate-name mismatch warnings.
persistFlags
. Map<name, boolean>. Re-sent on every SUBSCRIBE.
lastSeen
. Map<name, number>. Lamport of the last applied update; used to drop stale broadcasts and as
ifLamport
for CAS.
outbound
. Queue of frames pending an open socket (used both while disconnected and while
sendPaused
is set).
unserializableWarned
. Set<name> for the once-per-name JSON-stringify warning.
status
. Reactive
Signal<ConnectionStatus>
. Mirrors the WebSocket lifecycle.
reconnectDelay
,
reconnectAttempts
. Exponential-backoff state. Reset by
resetReconnectState()
.
closed
. Set true by
close()
; terminal.
manuallyDisconnected
. Set true by
disconnect()
; reverses on
reconnect()
. The close-event auto-reconnect bails out while set.
sendPaused
. Set true by
pauseSend()
;
send()
diverts to
outbound
while set.
connect()
Sets
status = 'connecting'
, allocates a new
WebSocket(this.url)
(bailing to
scheduleReconnect
if the constructor throws), and wires three listeners.
open. setStatus('connected')
→
resetReconnectState()
→ walk
this.signals.keys()
and re-send a
subscribe
frame for each → drain
outbound
. Both loops go through
rawSendOnSocket(ws, msg)
so
onFrame
fires.
message.
Delegates to
handleMessage(raw)
.
close.
Nulls
this.ws
and calls
scheduleReconnect()
.
error.
Logged via
console.error
. The reactive
status
signal covers the connection state.
scheduleReconnect()
Bails when
closed
or
manuallyDisconnected
is set. Compares
reconnectAttempts
against
reconnectOpts.maxRetries ?? Infinity
; on exhaustion, sets status to
'disconnected'
and stops scheduling. Otherwise sets status to
'reconnecting'
and queues a
setTimeout(connect, delay)
.
reconnectDelay
doubles up to
maxDelay
after each scheduled attempt.
handleMessage(raw)
Decodes the JSON, fires
notifyFrame('in', msg)
, then dispatches on
msg.type
:
snapshot
. Iterates
msg.values
; for each pair applies
sig._setFromRemote(value)
and sets
lastSeen.set(name, msg.lamport ?? 0)
.
update
/
batch-update
. Both dispatch to
applyRemoteUpdate(name, value, lamport)
, which drops stale broadcasts (
lamport <= seen
) and otherwise calls
_setFromRemote
.
set-ok
. Updates
lastSeen
, takes the pending write entry via
takePendingWrite(opId)
, resolves its promise.
set-fail
. Takes the pending entry, applies the server's authoritative value to the local Signal via
_setFromRemote
, bumps
lastSeen
, and either retries (
reason === 'conflict'
on a CAS write) via
retryCas(pending)
or rejects the promise with a
LiveSetRejected
Error.
error
.
canRead
subscribe rejection. Logged via
console.error
since there is no per-call surface to route to.
getOrCreateSignal(name, initial, options)
On a cached hit, runs the duplicate-name mismatch warnings and returns the existing Signal.
On a miss, builds a fresh Signal and wires four hooks before returning it:
Wrap sig.set.
The wrapper detects
typeof valueOrFn === 'function'
and dispatches to
casUpdate(...)
. The direct path checks
checkSerializable
, calls the original
origSet
, and sends
{ type: 'set', name, value }
.
Wrap sig.stop.
After
origStop
runs, calls
this.unsubscribe(name)
to tear down the server-side subscription too.
Install _onZeroSubscribers
/
_onFirstSubscriber
.
When local subscribers reach zero, send
MSG_UNSUBSCRIBE
. When they go from 0 → 1, send
MSG_SUBSCRIBE
. The two hooks are how the Signal class's sleep/wake cycle reaches the transport.
Register and subscribe. signals.set(name, sig)
followed by
send(buildSubscribeMsg(name, persist))
.
CAS. casUpdate / sendCasWrite / retryCas
casUpdate(name, sig, origSet, fn)
runs
fn(sig.value)
synchronously for the optimistic apply (catching throws and rejecting the returned Promise), checks serializability, calls
origSet(initialNext)
so subscribers see the new value immediately, and returns a Promise that
sendCasWrite
will eventually resolve or reject.
sendCasWrite(pending, value)
allocates a fresh
opId
, registers the pending entry in
pendingCas
, and sends
{ type: 'set', name, value, ifLamport, opId }
with
ifLamport = lastSeen.get(name) ?? 0
.
retryCas(pending)
bumps
pending.attempts
; if it exceeds
MAX_CAS_RETRIES
(8) the promise is rejected with a "high write contention" message. Otherwise it re-runs
pending.fn(sig.value)
against the freshly-applied server value, re-checks serializability,
_setFromRemote
s the new local value, and reissues via
sendCasWrite
.
Lifecycle methods
close()
. Sets
closed = true
, calls
dropSocket()
, rejects every pending CAS with "transport closed", and transitions to
'disconnected'
. Terminal.
disconnect()
. Sets
manuallyDisconnected = true
, calls
dropSocket()
, transitions to
'disconnected'
. Reversible via
reconnect()
.
reconnect()
. Calls
dropSocket()
, resets backoff, clears
manuallyDisconnected
, transitions to
'reconnecting'
, and queues a
connect()
via
queueMicrotask
so any same-tick caller-side state changes (display-name edit, etc.) land in the new URL.
pauseSend()
. Sets
sendPaused = true
.
send()
diverts outgoing frames to
outbound
. Reads continue to flow.
resumeSend()
. Clears the flag and flushes
outbound
via
rawSendOnSocket
in FIFO order.
unsubscribe(name)
. Drops the name from every internal Map and sends
MSG_UNSUBSCRIBE
. The local Signal stays valid for reads.
dropSocket()
is the shared "clear timer + close ws + null ref" helper used by all three of
close
/
disconnect
/
reconnect
.
Server runtime. server.js
server.js
exports the
liveServer(opts)
factory. The function is
async
because the sqlite store loads via dynamic import; memory awaits uniformly.
Every write funnels through
commitWrite(name, value, fromSocket)
: increment
lamport
, update
registry
, persist if
persistPolicy
says so,
broadcast(name, fromSocket)
(excludes the originator), then
notifyObservers(name, value)
for the server-side observer set.
applySet(name, value, fromSocket)
is the thin wrapper that runs
checkSerializable
first. It is called from the wrapped server-side
sig.set
and from
live.set
.
handleClientSet
bypasses
applySet
and goes straight to
commitWrite
after running its own canWrite / CAS / serializability checks.
handleClientSet
is the entry point for inbound
MSG_SET
. It assembles the
{ prev, next }
transition, runs the global
canWrite
and any per-signal predicate, runs the CAS check when
ifLamport
is present, runs
checkSerializable
, then calls
commitWrite
and
replyToClientSet(...true)
.
rejectClientSet(sock, name, msg, reason)
factors out the "rejection" path. CAS writers (
opId
present) receive a typed
MSG_SET_FAIL
with the server's authoritative value and lamport. Non-CAS writers receive a best-effort
MSG_ERROR
instead.
replyToClientSet(sock, name, opId, ok, reason)
returns early when
opId === undefined
(fire-and-forget writes), otherwise sends the matching
set-ok
or
set-fail
frame.
Broadcast batching
broadcast(name, exclude)
pushes
{ name, exclude }
onto
pendingBroadcasts
and schedules a
queueMicrotask(flushBroadcasts)
(guarded by
flushScheduled
).
flushBroadcasts()
groups the queued entries by destination socket. Sockets that pick up exactly one update receive a plain
MSG_UPDATE
; sockets that pick up two or more receive a single
MSG_BATCH_UPDATE
carrying the array. The grouping happens at flush time, not at broadcast time, so a burst of server-side writes in one tick coalesces correctly.
Transient-drop machinery
Names declared
persist: false
are dropped from the registry
TRANSIENT_GRACE_MS
(30s) after the last subscriber leaves. The grace period covers brief reconnects and the local sleep-wake cycle.
scheduleTransientDropIfNeeded(name)
. Bails if the policy is not
false
, if any subscriber remains (via
hasAnySubscriber
), or if a timer already exists. Otherwise sets a
setTimeout
that is
unref()
-ed so it doesn't pin the event loop.
cancelTransientDrop(name)
. Clears the timer; called on every fresh
MSG_SUBSCRIBE
and every
getOrCreateSignal
on the server.
dropTransient(name)
. The timer body. Re-checks
hasAnySubscriber
(in case a subscriber arrived during the grace window) and tears down all per-name maps + the store entry.
hasAnySubscriber(name)
. True when
subs[name]
or
nameSubscribers[name]
has anyone. The drop scheduler and
dropTransient
itself both consult it.
attach(httpServer)
Resolves the
ws
peer dep via
createRequire
anchored at the user's
process.cwd()
(so
link:
-style installs find it), with a fallback to a dynamic
import('ws')
. Throws a clear error when neither works.
Constructs a
new WebSocketServer({ server: httpServer, path })
, retains it on
attachedWss
, and wires per-connection handlers:
sock._kensingtonAlive = true
plus a
sock.on('pong', ...)
listener that re-flips the flag. The heartbeat below uses this.
await onSocketOpen(sock, req)
which runs the user's
onConnect
(catching throws and logging) and stores the returned ctx on the per-socket
socketState
.
sock.on('message', ...)
delegates to
onSocketMessage
, which dispatches on
msg.type
to the subscribe/unsubscribe/set handlers.
sock.on('close', ...)
calls
handleSocketClose
which removes the socket from every
subs[name]
, schedules transient drops, runs the user's
onSocketClose
callback, and clears the per-socket state.
Heartbeat. When
heartbeatInterval !== false
, a
setInterval
walks
wss.clients
every interval. For each socket, if
_kensingtonAlive === false
(the previous ping never got a pong),
terminate()
is called. Otherwise the flag is reset to false and a
sock.ping()
is sent. The pong listener flips the flag back to true. The interval is
unref()
-ed.
LiveServer handle. get / set / list / policyOf / delete
list(prefix)
. Walks the in-memory
registry
(NOT the persistence store) and returns
[[name, value], ...]
for every name starting with the prefix. Transient entries are visible.
delete(name)
. Cancels the transient drop, deletes from every map, and drops the cached server Signal.
Does not notify subscribers.
Use
set(name, null)
instead when subscribers must observe the removal.
Server-side liveSignal. makeServerSignal
When
liveSignal(initial, name)
runs on the server (via
serverTransport.getOrCreateSignal
), it returns a long-lived Signal cached in
serverSignals
. The Signal is built by
makeServerSignal
, which wraps
sig.set
so writes go through
applySet
(registry + broadcast + observer notification), and registers a
cb => sig._setFromRemote(value)
observer in
nameSubscribers[name]
. The cb makes the local Signal mirror client and server-initiated writes;
_setFromRemote
short-circuits when the value already matches, so the writer's own callback does not double-notify its subscribers.
Calling
sig.stop()
removes the cb, drops the cache entry, and (if the observer set is now empty) triggers
scheduleTransientDropIfNeeded(name)
.
close()
Tears down the WebSocket server first to stop in-flight broadcasts from racing on a half-closed socket. For each
attachedWss.clients
calls
terminate()
(skipping the close-handshake wait), then
wss.close()
. Clears the heartbeat interval. Drops every transient-drop timer. Clears
nameSubscribers
and
serverSignals
. Closes the persistence store.
Persistence adapters
Both adapters expose
get(name)
,
set(name, value)
,
delete(name)
,
all()
, and
close()
. The server warms the in-memory registry from
store.all()
at boot.
sqlite.js
. Lazy-loads
better-sqlite3
via
createRequire
anchored at the user's
cwd
. Maintains an in-memory mirror so reads stay sync. Writes are debounced via
setTimeout
(default 250ms) and grouped into a single transaction; the flush is
unref()
-ed so a pending debounce never pins the event loop.
Every validation report in the library funnels through one function in
show-invalid.js
.
showInvalid(message, validationLevel, logger)
implements the three-level contract that the rest of the codebase relies on.
'off'.
Does nothing. The function returns immediately without logging or throwing.
'warn'.
Calls
logger(error.stack)
with a stack-bearing Error.
'error'.
Throws the Error.
The function builds an
Error
from the message and passes it through
filter-stack.js
before either logging or throwing, so the surfaced stack points at the caller rather than at internal frames.
Warning throttling
warnings.js
owns the runtime diagnostics used by the reactive layer. It exports
throttledError
,
throttledWarn
, and the test helper
_resetWarningThrottle
. These back the repeated diagnostics fired from
signal.js
and
map-with-key.js
. Loop detection, invalid usage, duplicate keys.
Each diagnostic is keyed. A
warnLastSeen
Map records the last time each key fired. A repeated key is suppressed until
WARN_THROTTLE_MS
(1000ms) has elapsed, so a hot loop surfaces the problem once per window instead of flooding the console.
_resetWarningThrottle
clears the Map for tests.
Stack frame stripping
filter-stack.js
strips Kensington-internal frames from an Error so warnings and validation errors point at the caller's code rather than at library plumbing. It is shared by
show-invalid.js
and by the throttled functions in
warnings.js
.
Node.js
In Node the module reads
import.meta.url
, which is a
file:
URL pointing at the real file on disk. It derives the
esm/
directory from that URL and removes every stack frame whose location includes that path. User frames live elsewhere, so they survive.
Browser bundle
In a browser bundle Kensington and user code share the same bundle URL, so there is no reliable way to tell an internal frame from an external one. Filtering by the bundle URL would strip the user's own frames too, leaving only native-code frames. The filter therefore preserves the full stack whenever it cannot make that distinction, including IIFE bundles where
import.meta
is absent entirely.
Devtools hook
devtools.js
implements the
window.__KENSINGTON_DEVTOOLS__
hook.
enableDevtools()
activates the hook once. Subsequent calls are no-ops, and calling it on the server only logs a warning.
The hook object exposes:
signals
,
effects
, and
bindings
Maps.
domTrackedCount
. The current count of DOM-tracked elements.
An event bus.
on(event, cb)
,
off(event, cb)
, and
_emit(event, data)
.
Signal IDs and effect IDs are monotonically increasing integers assigned at creation time. A
signalGcRegistry
(a
FinalizationRegistry
, or a no-op stub where the constructor is unavailable) removes devtools entries for signals that are garbage-collected without an explicit
.stop()
call.
When a signal reaches zero subscribers its ID is added to a
pendingZeroSubscribers
Set and scheduled for removal on a
queueMicrotask
. A re-subscription in the same turn cancels the removal, which is what keeps signals in the panel across a drag-reorder pause and resume. A
computedSigs
WeakSet records which signals were created via
computed()
so a sleeping computed can be restored with
isComputed: true
when it wakes.
markNextEffectAsBinding(label)
sets a one-shot flag that routes the next created effect into
hook.bindings
instead of
hook.effects
, tagged with the given label. This is how DOM-binding effects are distinguished from user effects in the panel.
State preservation
preserve-state.js
captures and restores user-visible DOM state across a node replacement.
captureState(root)
walks the subtree and returns a snapshot.
restoreState(root, state)
applies it to a freshly built subtree. It is used by
rebuildNode
in
reconcile.js
when a
mapWithKey
per-key inner re-emits a fresh tag, and by
hmrReplaceComponent
in
hydration.js
when a live mount is re-rendered.
What is preserved
The focused element, recorded as a child-index path from the root,
plus its text selection range where applicable.
scrollTop
and
scrollLeft
on the root and every scrollable descendant.
value
,
checked
, and
indeterminate
on INPUT and TEXTAREA.
value
on SELECT.
open
on DETAILS and DIALOG.
The mapping between old and new nodes is positional. It assumes the tree shape is identical between renders, which holds when only signal references differ. State for a position that does not resolve on the new tree is silently dropped.
What cannot be preserved
Some state lives only on the original element instance and is lost on replacement.
A page can end up loading more than one copy of the Kensington module, for example when a dependency bundles its own copy. An
instanceof
check against this copy's classes would reject tags and signals built by the other copy. To accept them, the library brands its prototypes with marker properties and checks the marker instead.
Tag markers
In
content-tag.js
,
ContentTag.prototype._isKensingtonTag = true
and
ContentTag.prototype._isKensingtonContentTag = true
. The content-only marker is set on ContentTag and its subclasses.
literal-tag.js
and
comment-tag.js
each set
_isKensingtonTag = true
on their own prototype but not the content marker.
The local helper
isKensingtonTag(c)
checks
c._isKensingtonTag === true
and replaces the direct class checks in content validation. The separate
_isKensingtonContentTag
marker lets
toElement
recurse only into content tags, for example to pass the
_inheritPersist
option down to child ContentTag nodes.
Signal marker
In
signal.js
,
Signal.prototype._isKensingtonSignal = true
, and
isKensingtonSignal(v)
checks
v._isKensingtonSignal === true
. This is why a Signal from a different module copy is accepted anywhere a value is accepted, including by
attributeValueIsValid
.
Slim build via Proxy
The full bundle ships the generated
Kensington
class, which declares a method for every HTML, SVG, and MathML element along with that element's attribute spec data. The slim build replaces that generated class with a hand-written Proxy in
kensington-slim.js
. It carries no per-element attribute spec data, so it is about 5x smaller minified (roughly 148 KB down to roughly 27 KB), and signal-only consumers tree-shake it down to about 1.5 KB.
Why a Proxy
On the full build every tag method is a generated class field. On the slim build the constructor returns
new Proxy(this, ...)
so that no tag methods exist up front. A property access like
t.div
is resolved dynamically by the Proxy's
get
trap. The trap checks real instance members first (via
Reflect.has
), then looks the property up in a small
tag-info.js
table that maps a method name to a single-letter type code, then builds and caches a
createTag
closure for it. Because nothing is generated per element, the class body stays tiny.
Resolved closures are memoized in a
tagCache
, so repeated access to the same tag is a plain map lookup after the first resolution. Real instance methods are bound in the constructor so destructuring such as
const { div } = t
works the same as on the full build.
// esm/kensington-slim.js (abridged)
return new Proxy(this, {
get(target, prop, receiver) {
if (Reflect.has(target, prop)) {
return Reflect.get(target, prop, receiver);
}
if (typeof prop !== 'string') { return undefined; }
const cached = tagCache[prop];
if (cached !== undefined) { return cached; }
const info = tagInfo[prop];
if (info === undefined) { return undefined; }
// entry is a bare type code, or [code, tagName] when they differ
const [tagType, tagName] = Array.isArray(info) ? info : [info, prop];
const fn = target.createTag(tagName, opts.Klass, { ... });
tagCache[prop] = fn;
return fn;
},
});
Forced validationLevel: off
The slim build ships no attribute spec data, so runtime validation is impossible. The constructor throws if
validationLevel
is anything other than
'off'
. The slim build does still ship the set of camelCase attribute names (
camelCaseNames
) so that
getAttrName
preserves case for SVG attributes such as
viewBox
rather than kebab-casing them.
How it is produced
The slim class itself is hand-written. The lookup table it reads is generated.
build-tag-info.js
emits
tag-info.js
as part of the normal generator run, mapping each method name to a type code (and, where the method name differs from the element name, to the element name too). The bundle swap happens in the rollup config at
build-browser.js
. A
slimPlugin
rewrites four source modules during the slim build.
esm/kensington.js
resolves to
esm/kensington-slim.js
,
esm/attributes.js
becomes a stub that exports only
__slim__
and
camelCaseNames
, and the devtools and stack-filter modules become no-op stubs. The entry point stays
index.js
, so the swap is invisible to consumers.
The tree-shaking win comes from
index.js
, where the shared instance is created with a
/* @__PURE__ */
annotation on
new Kensington()
. Combined with the package's
sideEffects
marking, a bundler can drop the class entirely for a consumer that imports only the reactive primitives. The slim bundles are exposed through the
./dist/slim
and
./dist/slim/min
package exports.
CLI. html-to-kensington
The package ships a
kensington
binary, wired through the
"bin"
field of
package.json
to
html-to-kensington.js
. It reads HTML and prints the equivalent Kensington method-call code. The entry point also handles
--help
, argument validation, and the
--copy
(
-c
) flag.
Input sources
HTML can arrive three ways. A file path argument is read with
readFileSync
. A pipe or redirect is read from stdin as a stream. An interactive terminal prompts for a paste and uses bracketed paste mode, where the terminal wraps the pasted text in
ESC[200~
and
ESC[201~
so the reader knows exactly when the paste ends without requiring Ctrl+D. All of this lives in
read-html.js
.
The conversion pipeline
flowchart LR
A[HTML input] --> B[parse5]
B --> C[convert-html.js]
C --> D[node-to-code.js per node]
D --> E[attrs-to-code.js]
D --> F[formatter.js]
F --> G[stdout]
F --> H[clipboard.js --copy]
convert-html.js
parses the input with parse5. A full document (one that starts with a doctype or an
<html>
tag) is parsed with
parse
, and a fragment is parsed with
parseFragment
. It then walks the root nodes and delegates each to
node-to-code.js
. A document with a doctype on its root
<html>
element converts to
htmlWithDocType
.
node-to-code.js
converts a single parse5 node into a Kensington method call string. Text nodes become JSON string literals (blank text is dropped), comment nodes become
t.inlineComment(...)
, and element nodes become
t.tagName(attrs, content)
. SVG element names are restored to their correct case via a lookup table. Children are converted recursively. The function decides between an inline single-line call and a multi-line array of children based on the formatter's line width.
attrs-to-code.js
converts a parse5 attribute list into a JS object literal string. It groups attributes that share a first hyphen segment so that
data-*
and
aria-*
attributes can use the nested object notation, expands the
style
attribute into an object, and converts kebab-case names to camelCase. Boolean attributes with an empty value render as
name: true
.
Formatting and output
formatter.js
detects a formatter in the current working directory. It tries Prettier first, then ESLint, then falls back to an identity pass. The detected tool supplies a maximum line length (Prettier's
printWidth
or ESLint's
@stylistic/js/max-len
), which the converter uses to decide where to break lines, and it formats the generated code before it is printed.
The formatted result is written to stdout. With
--copy
or
-c
, it is also sent to the system clipboard by
clipboard.js
, which shells out to
pbcopy
on macOS,
clip
on Windows, and
xclip
on Linux. A failed clipboard copy is silently ignored so the printed output is never blocked.
Invariants
The rules that hold across every code path. Violations are bugs.
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 reconciler never patches existing nodes in place.
A matched key resolves to either the same cached tag (DOM reused as-is) or a fresh tag (a full rebuild via
rebuildNode
plus
preserve-state.js
). Reactive updates flow through
_bindingEffect
subscriptions on the cached tag, not through per-render attribute diffs.
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 and flags the run as a DOM binding. Callers are
lifecycle.js
(via
_bindingEffect
),
literal-tag.js
,
comment-tag.js
, and
map-with-key.js
.
Where to look
If you're fixing a bug or adding a feature, here's where the change probably belongs.
If you're working on...
Look at...
A new attribute type or validation rule
validate.js
. Either attributeValueIsValid or validateAttributeByType
client.js
for client transport.
server.js
for the registry + broadcast multiplexer + heartbeat.
protocol.js
for the wire format.
state.js
for the process-wide transport handle.