@llui/dom
Current package version: 0.13.1
Runtime for the LLui web framework — The Elm Architecture on a compile-time-optimized signal runtime.
No virtual DOM. view() runs once at mount, building real DOM nodes with reactive bindings; a chunked-mask reconciler updates only the bindings whose dependency paths actually changed.
Install
pnpm add @llui/dom
Quick Start
import { component, mountApp, button, text } from '@llui/dom'
type State = { count: number }
type Msg = { type: 'inc' } | { type: 'dec' }
const Counter = component<State, Msg>({
name: 'Counter',
init: () => ({ count: 0 }),
update: (state, msg) => {
switch (msg.type) {
case 'inc':
return { ...state, count: state.count + 1 }
case 'dec':
return { ...state, count: state.count - 1 }
}
},
// `state` is a Signal<State> — derive reactive values with `.map` / `.at`.
view: ({ state, send }) => [
button({ onClick: () => send({ type: 'dec' }) }, [text('-')]),
text(state.map((s) => String(s.count))),
button({ onClick: () => send({ type: 'inc' }) }, [text('+')]),
],
})
mountApp(document.getElementById('app')!, Counter)
The view bag and signal handles
view receives { state, send }. state is a Signal<State> — a read handle, not the value:
state.at('field')— narrow to a sub-path signal (state.at('user').at('name')).state.map((s) => …)— derive a reactive value; the binding's mask tracks exactly the paths read.state.peek()— one-shot read, for handlers / effects /onMount(never as a slot value).
Element helpers (div, button, …) and structural primitives (each, show, branch, …) are module imports, not bag members. Combine multiple signals with derived([a, b], (av, bv) => …).
Mountable — everything you build is a lazy description
Every authoring helper (el/div/text/each/show/branch/unsafeHtml/lazy/virtualEach/foreign/portal/provide) returns a Mountable — a recipe materialized into live DOM at the point it is placed (as an element child, or in a view / arm / row return). Consequences:
- Annotate view helpers
Renderable(readonly Mountable[]— a list) orMountable(a single element) — notNode/Node[]. - Capture and reuse freely. A
Mountablestored in a variable and reused across ashow/branchremount rebuilds fresh each time; placing one twice yields two independent live instances. - Side-effect helpers must be placed.
onMount(cb)registers nothing unless its returnedMountableis in the view array — it is not an eager side effect. - Raw DOM interop: wrap an existing node with
mountable(() => node).
API
Core
| Export | Purpose |
|---|---|
component(spec) |
Define a component (init / update / view / onEffect?) |
mountApp(el, def) |
Mount a component into a container element |
mountSignalComponent(target, def, opts?) |
Lower-level mount — container or { anchor } target, optional hydrate |
hydrateSignalApp(target, def, serverState) |
Hydrate server-rendered HTML |
derived(sigs, fn) |
Combine N signals into one derived signal |
pathHandle / isSignalHandle |
Construct / detect a runtime signal handle |
tagSend(handler, variants) |
Tag an event handler with the msg variants it can send (agent protocol) |
View content
| Export | Purpose |
|---|---|
text(value) |
Reactive (or static) text node |
unsafeHtml(value) |
Render a raw HTML string (escape hatch; caller owns sanitization) |
each(items, { key, render }) |
Keyed list |
show(cond, render, orElse?) |
Conditional render (the condition signal is narrowed for the arm) |
branch(value, discriminant, arms) |
Discriminated-union / keyed render |
virtualEach(opts) |
Windowed keyed list (uniform number or per-item (item, index) => number row height) |
lazy(opts) |
Async-loaded child component with fallback / error |
foreign(spec) |
Imperative-library boundary (declared signals → LiveSignals) |
portal(content, target?) |
Render into a different DOM location (default document.body) |
onMount(cb) |
Run after mount; return a cleanup. Place the returned marker. |
mountable(build) |
Wrap a build closure / raw node as placeable content |
| element helpers | div, span, button, input, a, h1–h6, ul/li, table/tr/td, svg/path/…, 60+ total |
Context
| Export | Purpose |
|---|---|
createContext(default, name?) |
Create a context |
provide(ctx, value, render) |
Provide a value to everything render builds |
useContext(ctx) |
Read the nearest provided value (or the default) |
SSR
| Export | Purpose |
|---|---|
renderToString(def, state, env) |
Render a component to an HTML string |
renderNodes(def, state, env) |
Render to detached nodes (adapter use) |
serializeNodes(nodes) |
Serialize nodes to an HTML string |
browserEnv / DomEnv |
SSR document-env contract (pick a backing DOM below) |
Sub-path Exports
import { installSignalDebug } from '@llui/dom/devtools' // dev/agent relay — kept out of prod bundles
import { jsdomEnv } from '@llui/dom/ssr/jsdom' // server: jsdom-backed DomEnv
import { linkedomEnv } from '@llui/dom/ssr/linkedom' // server: linkedom-backed DomEnv
import { subApp } from '@llui/dom/escape-hatch' // isolated child TEA loop (rare)
// '@llui/dom/internal' — render-context glue for sibling adapter packages (e.g. @llui/vike)
Performance
Competitive with the fastest fine-grained reactive frameworks on js-framework-benchmark — see the benchmarks page.
Functions
__currentBuildInfo()
Adapter hook (@llui/vike): the build currently in progress, or null when
called outside a signal build. Exposes the build's doc (to create anchor
nodes that belong to the same document as the surrounding tree) and a SNAPSHOT
of the context values in scope at the call site (so an adapter that mounts a
NESTED build in a separate pass can replay them via runBuild's seedContexts
/ the contexts mount option). Returns a fresh snapshot map — safe to retain.
function __currentBuildInfo(): {
doc: SignalDoc
contexts: ReadonlyMap<symbol, unknown>
} | null
__registerScopeVariants()
Compiler-emitted (signal connect-translator path) + library helper: register the variants for the active build scope. No-op outside a build.
function __registerScopeVariants(variants: readonly string[]): void
applyAttr()
Apply a single non-reactive prop VALUE to node: style.* → individual style
property, form-control IDL props (value/checked/selected/indeterminate)
→ live property assignment, everything else → content attribute (null/false
removes, true sets empty). Exported so a compiler-emitted {@link RowFactory}'s
reactive-prop commit routes through the same DOM-application logic the
authoring path uses (rather than re-inlining the IDL/style quirks).
function applyAttr(node: Element, name: string, value: unknown): void
base()
Set the document <base> (one per document — dedups to a single tag).
function base(attrs: BaseAttrs): Mountable
bodyAttr()
Set attribute(s) on <body> (e.g. bodyAttr({ class: theme })).
function bodyAttr(attrs: Record<string, HeadValue<string | boolean | null>>): Mountable
branch()
Discriminated-union render. discriminant selects the union's tag field
(v => v.kind, v => v.type, …); each arm receives the NARROWED variant
signal, so it can read variant-only fields with full types (v.at('data')).
Mirrors show's narrowing. Rewritten by the compiler to signalBranch.
export function branch<U extends object, D extends keyof U>(
value: Signal<U>,
discriminant: (u: U) => U[D],
arms: {
[K in U[D] & (string | number)]: (v: Signal<Extract<U, Record<D, K>>>) => Renderable
},
/** Optional element-level transition hooks — animate the arm swap (see `show`). */
transition?: TransitionOptions,
): Mountable
export function branch<K extends string | number>(
value: Signal<K>,
arms: Partial<Record<K, () => Renderable>>,
/** Optional element-level transition hooks — animate the arm swap (see `show`). */
transition?: TransitionOptions,
): Mountable
browserEnv()
Wrap the browser globals as a DomEnv. Used as the default env for
mountApp / hydrateSignalApp on the client.
The returned object delegates to globalThis.document / globalThis.X
lazily — evaluating browserEnv() on a server process before a DOM
exists is safe because the delegation only dereferences the globals
when a method is actually called.
Never mutates globalThis. A process with no browser globals that
invokes one of the factory methods gets a TypeError / ReferenceError
at the call site — which is correct: you're trying to build DOM on a
runtime that has no DOM.
function browserEnv(): DomEnv
collectHeadSink()
function collectHeadSink(): CollectHeadSink
component()
Define a signal component. Identity at runtime — the view has been lowered by
the compiler; the authoring/runtime bag shapes coincide (state: Signal).
The three type parameters:
S— State. The component's state shape. Must be JSON-serializable (plain objects/arrays/primitives — no class instances, functions, Maps, or Dates) so it can be snapshotted, time-travelled, and sent over the agent wire. Inview,statearrives as aSignal<S>— read it withstate.at('field'),state.map(fn), orstate.peek()(handlers/effects).M— Msg. The message/action union the reducer handles. A discriminated union with atypefield ({ type: 'inc' } | { type: 'set'; value: number }); thetypediscriminant is what the compiler, devtools, and agent surface key off. Enforced byM extends { type: string }.E— Effect. The effect union returned frominit/update, also a discriminated union with atypefield. Defaults tonever(a pure component with no effects). Handled inonEffect(or by@llui/effects).
Spelling these out (and the { type: string } constraint) catches a malformed
Msg/Effect union at the call site instead of at the first failed dispatch.
function component<S, M extends { type: string }, E extends { type: string } = never>(spec: SignalComponentSpec<S, M, E>): SignalComponentDef<S, M, E>
createContext()
function createContext<T>(defaultValue: T, name = 'context'): Context<T>
createTeaDriver()
Create a supported, view-less LLui TEA driver.
This is the same reduction and effect-scheduling path used by
mountSignalComponent, with the DOM commit replaced by the optional
{@link TeaDriverOptions.onStateChange} observer. Initial effects run after the
driver and its live state handle are ready. The driver is synchronous: a call
to send returns only after reentrant effect-driven messages have settled.
function createTeaDriver<S, M, E = never>(program: TeaProgram<S, M, E>, options: TeaDriverOptions<S, M, E> = {}): TeaDriver<S, M>
currentDoc()
The document of the ACTIVE build — a live client Document on the client, a
server DomEnv under SSR. Public so sibling modules can create nodes / read
head/documentElement/body in the same environment the runtime builds in.
Must be called during a build.
function currentDoc(): SignalDoc
derived()
Combine N independent signals into one derived signal. Use when the inputs have no shared parent signal (cross-tree, or a per-row item signal + a component-state signal); for a single source, prefer {@link Signal.map}.
Two call forms — pick whichever reads cleaner:
derived(a, b, (va, vb) => …) // variadic: 2–4 sources, positional values
derived([a, b, …c], (…vals) => …) // array: any N, tuple-typed values
The compiler lowers derived(...) inside a DIRECT view to an inline call. This
is the equivalent RUNTIME handle for view-helper composition (where there is no
statically-known path): produce/peek apply fn over the resolved sources and
deps is the UNION of the sources' deps — so the chunked-mask reconciler fires
the binding whenever ANY source changes, and commits only on an output change.
All inputs must resolve against the same binding state (the common case: each is
rooted at the component state, or all at the same row ctx). The result is a
{@link MappedSignal} — like a .map(), it carries no path, so .at() on it is
a compile error (slice the sources before combining).
export function derived<A, B, U>(a: Signal<A>, b: Signal<B>, fn: (a: A, b: B) => U): MappedSignal<U>
export function derived<A, B, C, U>(
a: Signal<A>,
b: Signal<B>,
c: Signal<C>,
fn: (a: A, b: B, c: C) => U,
): MappedSignal<U>
export function derived<A, B, C, D, U>(
a: Signal<A>,
b: Signal<B>,
c: Signal<C>,
d: Signal<D>,
fn: (a: A, b: B, c: C, d: D) => U,
): MappedSignal<U>
export function derived<T extends readonly unknown[], U>(
sigs: { readonly [K in keyof T]: Signal<T[K]> },
fn: (...values: T) => U,
): MappedSignal<U>
domHeadSink()
Build a sink that writes to doc's live <head> / <html> / <body>. Returns
an inert sink when the document has no <head> (server DomEnv — those go
through the collector instead).
function domHeadSink(doc: SignalDoc): HeadSink
each()
function each<T>(items: Signal<readonly T[]>, opts: {
key: (item: T) => string | number
render: (item: Signal<T>, index: Signal<number>) => Renderable
/** Optional element-level transition hooks (from `@llui/transitions` — e.g.
* `fade()`, `flip()`, `mergeTransitions(fade(), flip())`): `enter` animates
* newly-inserted rows, `leave` defers a removed row's detach until its promise
* resolves, and `onTransition` runs after each keyed reconcile so FLIP can
* glide surviving rows to their new positions. Omitted ⇒ no animation (the
* reconcile is unchanged). Never runs under SSR. */
transition?: TransitionOptions
}): Mountable
eachArm()
Compiled render-arm keyed list — the MID-TIER between {@link eachDirect}
(full direct construction) and the verbatim authoring {@link each}: the items
source is a verbatim runtime handle (a view-helper's call-site-bound signal the
compiler can't resolve), but the ROW is a compiled () => [...] arm whose
binding producers read the combined row ctx (ctx.item / ctx.index) directly
— no per-row item/index handle allocation. Un-lowerable children inside the arm
(a nested show on a state handle, a helper call without the row param) stay
verbatim and run via the authoring path within the row build. Emitted by the
compiler's pass-2 helper-each lowering when the row factory bails on a
structural child.
function eachArm<T>(items: Signal<readonly T[]>, key: (item: T) => string | number, render: (getCtx: () => RowCtx<T>) => Renderable, stateDeps?: readonly string[]): Mountable
eachDirect()
Direct-construction keyed list. Same keyed reconcile as {@link each}, but each
row is built by row (a {@link RowFactory}: direct DOM + binding specs wired by
node reference) instead of authoring helpers — the compiled fast path. The
factory's spec produce(ctx) reads the row ctx { item, state, index }.
stateDeps names the component-state paths the factory's bindings read (the
compiler passes the collected set, often empty); omitted (legacy emissions),
it falls back to whole-state so ctx.state reads stay live.
function eachDirect<T>(items: Signal<readonly T[]>, key: (item: T) => string | number, row: RowFactory, stateDeps?: readonly string[]): Mountable
el()
Build an element. on* function props become event listeners; react(...)
props become reactive bindings; everything else is a static attribute. Returns a
Mountable that creates the element and materializes its children when placed.
function el(tag: string, props: Readonly<Record<string, PropValue>> = {}, children: readonly ChildNode[] = []): Mountable
elNS()
Build an SVG-namespaced element (svg/path/g/circle/…). Same prop/child
semantics as el, via createElementNS.
function elNS(tag: string, props: Readonly<Record<string, PropValue>> = {}, children: readonly ChildNode[] = []): Mountable
foreign()
Embed an imperative library. Declared state signals are materialized to
LiveSignals for mount. A REAL runtime helper (like text/each/show/branch):
the compiler lowers a direct-view foreign() to signalForeign, but in
view-helper functions / uncompiled code it runs here — converting each declared
state HANDLE to its {produce, deps} spec and delegating to signalForeign.
function foreign<Inst, State extends Record<string, Signal<unknown>>>(spec: {
tag?: string
state?: State
mount: (args: {
el: Element
state: { [K in keyof State]: LiveSignal<State[K] extends Signal<infer T> ? T : unknown> }
}) => Inst
unmount?: (instance: Inst) => void
}): Mountable
htmlAttr()
Set attribute(s) on <html> (e.g. htmlAttr({ lang })). Each attribute
dedups independently and restores its pre-existing value on unmount.
function htmlAttr(attrs: Record<string, HeadValue<string | boolean | null>>): Mountable
hydrateSignalApp()
Hydrate a signal component over server-rendered HTML in container. Builds the
client tree against serverState (matching the SSR render) and atomically
swaps it in — server HTML stays visible until the swap, so no flash. init()'s
effects are skipped by default (already run on the server); pass
runInitEffects: true for init()s that no-op on the server.
function hydrateSignalApp<S, M, E = never>(target: Element | MountTarget, def: SignalComponentDef<S, M, E>, serverState: S, options?: { runInitEffects?: boolean; contexts?: ReadonlyMap<symbol, unknown> }): SignalComponentHandle<S, M>
isFrameworkError()
Is this throw a framework authoring invariant (so: fatal), rather than a data surprise the mount boundary should contain and report?
Brand check — see this module's header for why it is not instanceof.
function isFrameworkError(err: unknown): boolean
isMountable()
function isMountable(v: unknown): v is Mountable
isSignalHandle()
function isSignalHandle(v: unknown): v is SignalHandle<unknown>
lazy()
Load a signal component asynchronously: render fallback() immediately, then
swap in the loaded component when loader() resolves (or error(err) on
reject). Identity at runtime — a real runtime helper (not compiled away), so
view-helper composition and uncompiled tests can call it directly.
function lazy<LS = unknown, LM = unknown, LE = unknown>(opts: SignalLazyOptions<LS, LM, LE>): Mountable
link()
Add a <link> tag (canonical, preload, stylesheet, …). Dedups by rel+href.
function link(attrs: LinkAttrs): Mountable
mapSend()
Adapt a parent dispatcher to a child message type by wrapping each child message in the parent's corresponding message variant.
function mapSend<Outer, Inner>(send: Send<Outer>, wrap: (inner: Inner) => Outer): Send<Inner>
mergeStaticHead()
Merge a static +Head.ts head string with collected component head, letting
component entries WIN: any <title> / <meta name|property> in staticHead
whose key the component also set is stripped, so the document never carries two
<title>s (the browser would silently use the first). Returns
strippedStatic + collected.head.
function mergeStaticHead(staticHead: string, collected: CollectedHead): string
meta()
Add a <meta> tag. Dedups by name/property/httpEquiv/charset.
function meta(attrs: MetaAttrs): Mountable
mountable()
Wrap a build closure as a Mountable. build runs (with a live ctx) when the
Mountable is placed — see populate/runBuild. Public so adapter packages
(@llui/vike's pageSlot) and raw-DOM interop can produce placeable view content:
mountable(() => someRawNode). Note the build runs once per placement, so a build
that returns a captured node (rather than creating a fresh one) reintroduces the
single-parent footgun — create the node inside the closure.
function mountable(build: () => Node): Mountable
mountApp()
Mount a signal component into a container.
function mountApp<S, M, E = never>(container: Element, def: SignalComponentDef<S, M, E>, opts?: MountSignalOptions<S>): SignalComponentHandle<S, M>
mountSignal()
Mount a signal view: build the nodes (collecting bindings), attach them at the target, and wire a chunked-mask reconciler over the collected bindings.
For a container target, 'append' (fresh mount) leaves existing children and
'replace' swaps server HTML out atomically (hydration). For an anchor target,
the nodes are inserted immediately after the anchor comment and bracketed by a
synthesized end sentinel — dispose() removes that bracketed region.
seedContexts seeds the build's root context values (see runBuild); used by
adapters mounting a nested build whose providers live in a different pass.
function mountSignal(target: Element | MountTarget, initial: unknown, build: () => Renderable, modeOrSeed?: 'append' | 'replace' | ReadonlyMap<symbol, unknown>, seedContexts?: ReadonlyMap<symbol, unknown>, getState?: () => unknown): SignalMount
mountSignalComponent()
Mount a signal component and drive its update loop. The target is a container
Element (fresh mount appends; hydration replaces) OR a MountTarget
descriptor — including { anchor } for adapters mounting a nested layer as
siblings of a slot anchor. With opts.hydrate, takes over server-rendered
HTML (see MountSignalOptions).
function mountSignalComponent<S, M, E = never>(target: Element | MountTarget, def: SignalComponentDef<S, M, E>, opts?: MountSignalOptions<S>): SignalComponentHandle<S, M>
normalizeUpdateResult()
Normalize an init() / update() result — a [state, effects] tuple or a
bare state — to a pair. This is the one place the shape heuristic lives;
mounted components, SSR, the view-less driver, and @llui/test all use it.
A two-element array whose second element is itself an array is read as the
tuple; every other value is a bare state with no effects. Consequently, a
state that is itself a two-tuple with an array in its second slot is ambiguous
and must be returned explicitly as [state, []].
function normalizeUpdateResult<S, E>(result: S | [S, E[]]): [S, E[]]
noscript()
Add a <noscript> with body as its text content.
function noscript(body: HeadValue<string>): Mountable
onMount()
Register a callback to run after the surrounding view's nodes are mounted, receiving the mounted parent element. Returning a function registers a teardown (run on unmount / dispose). Returns a marker node for the view array.
function onMount(cb: (root: Element) => void | (() => void)): Mountable
onTeardown()
Register a teardown to run when the owning scope is disposed (unmount). Public companion to {@link registerBinding} for sibling modules. Must be called during a build.
function onTeardown(fn: () => void): void
pathHandle()
A path-rooted handle: produce resolves base from the binding state;
peek reads the live value via get. at extends the path; map derives.
rowLocal marks a handle rooted at a ROW ctx (the internal rowHandle for
item/index); it propagates through .at/.map so row locality is carried,
never string-inferred. Component-state handles default to false.
function pathHandle<T>(get: () => unknown, base: string, rowLocal = false): SignalHandle<T>
portal()
Render content into target (default document.body) instead of inline —
for overlays (dialog/popover/toast). The content's bindings join the current
scope (so it stays reactive); the content occupies an anchor-bracketed region in
the host, and a teardown clears that WHOLE region on unmount/dispose — including
anything a nested structural child (each/show/branch) mounted into it.
Returns an inline placeholder comment.
function portal(content: () => Renderable, target?: Element): Mountable
provide()
Provide value for context to everything render builds, then restore.
function provide<T>(context: Context<T>, value: T, render: () => Renderable): Mountable
react()
function react(produce: Producer, deps: readonly string[], componentRooted?: boolean): Reactive
registerBinding()
Register a reactive binding into the ACTIVE build whose commit applies the
value to a custom target (not an inline element). Public so sibling modules
(e.g. head/metadata management) can build bindings with non-element commit
targets that still ride the component's one chunked-mask reconciler — produce
runs on mount and whenever deps chunks go dirty; commit gets the new value.
Must be called during a build (inside a mountable(...) recipe).
function registerBinding(deps: readonly string[], produce: (state: unknown) => unknown, commit: (value: unknown) => void): void
renderNodes()
Build a signal component's DOM tree on the server, returning the (detached)
nodes plus a dispose that runs the build's teardowns. The caller composes /
serializes the nodes; effects are NOT dispatched (server render is pure).
For persistent layouts, compose multiple renderNodes results before
serializeNodes so the layout/page trees are stitched at the slot position.
function renderNodes<S, M, E>(def: SignalComponentDef<S, M, E>, initialState: S | undefined, env: ServerDoc, contexts?: ReadonlyMap<symbol, unknown>): { nodes: readonly Node[]; dispose: () => void }
renderToString()
Render a signal component to an HTML string against the initial state (or a
provided override). env is a server DomEnv from @llui/dom/ssr/jsdom or
@llui/dom/ssr/linkedom.
function renderToString<S, M, E>(def: SignalComponentDef<S, M, E>, initialState: S | undefined, env: ServerDoc): string
rowHandle()
A ROW-rooted path handle (item/index inside an each/virtualEach row).
Identical to {@link pathHandle} but branded rowLocal — so a spec built from it
(or from .at/.map off it) reads the row ctx and is NOT rebased to
ctx.state. This is the emission target for the compiled each-arm prelude
(const item = rowHandle(getCtx, 'item')) and the authoring each/virtualEach
item/index handles.
function rowHandle<T>(get: () => unknown, base: string): SignalHandle<T>
script()
Add a <script> (external via src, or inline via body). Dedups by static
id or src; otherwise anonymous (keyed by stable construction order).
function script(attrs: ScriptAttrs = {}, body?: HeadValue<string>): Mountable
serializeNodes()
Serialize an array of (already-built) DOM nodes to an HTML string. Used by
adapters (@llui/vike) that compose layout + page node trees before one final
serialization pass.
function serializeNodes(nodes: readonly Node[]): string
show()
function show<T>(cond: Signal<T>, render: (narrowed: Signal<NonNullable<T>>) => Renderable, orElse?: () => Renderable, transition?: TransitionOptions): Mountable
signalBranch()
Discriminated-union render. Mounts the arm matching the discriminant's current value; swaps arms when it changes (the old arm unmounts, the new one mounts as a child scope). Same-value updates do NOT remount — the mounted arm's child scope handles its own inner reactivity. An absent arm renders nothing.
function signalBranch(disc: ShowCond, arms: Readonly<Record<string, () => Renderable>>, transition?: TransitionOptions): Mountable
signalEach()
Keyed list primitive. A structural binding gated on the list's deps (items
path + row-state paths); on change it reconciles by key. Each row is its OWN
signal scope mounted on a combined { item, state } context — so a row reacts
to its item AND to component state, with per-row, per-binding gating (a shared
state change fans out only to the row bindings that read it; item changes hit
only that row). Kept rows are mutated in place, never recreated.
Reorder is move-minimizing via a longest-increasing-subsequence pass over the
rows' previous DOM positions: only n − |LIS| rows move, so a 2-row swap is 2
DOM moves and a single removal is 0 — not the O(n) re-insert the naive cursor
walk degraded to (swap/remove were ~6×/4× slower than peer frameworks).
function signalEach<T>(source: EachSource<T>, key: (item: T) => string | number, renderRow: (getCtx: () => RowCtx<T>) => Renderable, extraDeps?: readonly string[], transition?: TransitionOptions): Mountable
signalEachDirect()
Direct-construction keyed list: same keyed reconcile as {@link signalEach}, but each row is built by a {@link RowFactory} (direct DOM + direct binding wiring) instead of running authoring helpers per row. The compiler-emitted fast path for lowerable rows; also usable hand-written.
function signalEachDirect<T>(source: EachSource<T>, key: (item: T) => string | number, rowFactory: RowFactory, extraDeps?: readonly string[], transition?: TransitionOptions): Mountable
signalForeign()
Imperative-subtree boundary. Declared state signals are materialized to
LiveSignals (peek + bind) and handed to mount, which builds a third-party
instance into the host element. The signals stay reactive: when a declared
input changes, its LiveSignal fires bound callbacks. unmount runs on the
owning component's dispose. Communicate OUT via send (closed over from the
view bag). The analyzer sees the declared deps; the imperative body is opaque.
function signalForeign<Inst, State extends Record<string, SignalSpec<unknown>>>(spec: ForeignSpec<Inst, State>): Mountable
signalLazy()
Load a signal component asynchronously. Renders fallback() immediately as
siblings of an anchor comment (built in the CURRENT build, so the fallback is
reactive). When loader() resolves, the fallback region is removed and the
loaded component is mounted via mountSignalComponent({ anchor, mode:'append' })
— reusing the anchor-mount infra (nodes inserted after the anchor, bracketed by
an llui-mount-end sentinel; its handle owns that region's update loop and
dispose). If the loader rejects, error(err) is swapped in (or nothing).
If the surrounding build is torn down before the loader settles, a cancelled
flag skips the deferred mount; any already-mounted child handle is disposed.
The flag is re-checked AFTER the deferred mount too, so a teardown raised from
inside the child's own onMount still disposes it rather than orphaning it.
function signalLazy<LS = unknown, LM = unknown, LE = unknown>(opts: SignalLazyOptions<LS, LM, LE>): Mountable
signalShow()
Conditional render. Mounts render's content when the condition is truthy; if
an orElse arm is given, mounts it when falsy (otherwise nothing). The mounted
arm is its OWN scope that reads the owning component's state, registered as a
child of the owning scope — so while mounted it receives state updates (its
bindings re-run when THEIR deps change, not just when the condition flips).
Toggling the condition swaps arms; a same-truthiness update does NOT remount.
function signalShow(cond: ShowCond, render: () => Renderable, orElse?: () => Renderable, transition?: TransitionOptions): Mountable
signalText()
A reactive text node bound to a signal accessor. Returns a Mountable that
builds the text node and registers its binding when placed. componentRooted
(set by the authoring layer from the origin handle) drives correct row rebasing;
compiler-emitted calls omit it and rely on dep-string inference.
function signalText(produce: Producer, deps: readonly string[], componentRooted?: boolean): Mountable
signalUnsafeHtml()
Render a raw HTML string as live DOM nodes, inline between anchor comments (no
wrapper element). Reactive: when the bound string changes, the previously
inserted fragment is removed and the new HTML parsed in. The parsed nodes carry
NO reactive bindings — unsafeHtml is an escape hatch for pre-rendered markup
(markdown, syntax highlighting). The caller is responsible for trust/sanitization.
function signalUnsafeHtml(produce: Producer, deps: readonly string[], componentRooted?: boolean): Mountable
signalVirtualEach()
Virtualized keyed list — only the rows in the scroll viewport (+overscan) exist
in the DOM. A scroll container (fixed containerHeight, data-virtual-container)
holds an inner spacer (data-virtual-spacer) sized to the total height; each
visible row is absolutely positioned (translateY) at its cumulative offset.
On scroll the visible window is recomputed and rows are reconciled BY KEY using
the same per-row machinery as signalEach (per-row sub-build via runBuild
with inherit, a row scope mounted on a { item, state, index } ctx, teardowns
on removal). Rows scrolled out are disposed; rows scrolled in are built. The
window also recomputes when items changes (a spec gated on items.deps).
itemHeight is a uniform number (O(1) windowing) or a per-item function
(item, index) => number for variable-height rows (cumulative offsets via a
prefix sum, rebuilt when items changes). Heights come from the data —
measured/auto heights are not supported.
function signalVirtualEach<T>(spec: VirtualEachSpec<T>): Mountable
staticText()
A static text node.
function staticText(value: string): Mountable
style()
Add an inline <style> with css as its text content.
function style(css: HeadValue<string>, attrs: StyleAttrs = {}): Mountable
tagSend()
Library helper for *.connect implementations: tags an event
handler with the variants it dispatches at runtime, so the binding
registers them when the user spreads the bag onto an element.
Resolution rules — choose whichever is defined and non-empty:
-
send.__lluiVariants(translator pattern). When the user passed a compiler-tagged dispatch translator like(m) => dispatch({type: 'Auth/UserMenu'}),senditself carries the user-side variants the translator forwards. We surface those — the agent should see whatupdate()actually receives, not the library's internal Msg shape. -
libraryVariantsfallback. Whensendis the user's raw component send (no translator), the library's internal Msgs flow directly intoupdate(), so the library's own variants ARE the user variants. Library author hand-lists them once per handler.
Returns fn mutated (via Object.assign) so the same reference
remains identity-equal — important for downstream code that diffs
handlers across re-bindings.
libraryVariants and the type the handler dispatches are two
statements of ONE fact, and they used to be unchecked against each
other — a drifted tag lies to the agent about what a control does,
silently (issue #118). Two guards now cover it, and neither is
sufficient alone:
- the TYPE
readonly M['type'][](this signature) rejects a name that is not a Msg variant at all, including where the handler is a named function the compiler cannot read; and - the compiler's
tag-send-driftrule reads the handler and rejects a tag that names the WRONG variant — which type-checks, since'touch'and'blur'are equally validM['type'].
That rule BAILS rather than guess, so it is a backstop and not a proof: it
recognizes tagSend only when the binding's import provenance resolves to
@llui/dom (directly, through a barrel, or through a relative module inside
that package), reads only a literal variant list (as const and satisfies
are read through; a hoisted VARIANTS identifier is not) with an inline
handler, and reports an over-declaration only when nothing in the handler body
could dispatch unseen.
Library authors, one quiet consequence of that last bail. The
over-declaration half survives exactly one kind of call on a handler
parameter: a zero-argument preventDefault / stopPropagation /
stopImmediatePropagation. Any OTHER event method — e.persist(),
e.composedPath(), e.dataTransfer.setData(…) — is a call this analysis
cannot see into, so it forfeits completeness and silently switches the
"declares a variant it never dispatches" check off for that call site. The
failure is SILENCE, not a broken build: the dispatched-but-undeclared
direction still runs and nothing stops working. But do not read a clean build
of such a handler as confirmation that its variant list is exhaustive.
send is REQUIRED and non-nullable. Before #118 it was typed unknown and
read through ?., so a nullish first argument returned a tagged fn; it now
throws a TypeError. That is deliberate — the ?. existed only to satisfy
the cast the unknown parameter forced, and it disappeared with the cast. A
nullish dispatcher is now a type error, so only untyped or casting callers can
reach the throw, and failing at BIND time is earlier and closer to the mistake
than failing on the first click. (It is NOT an argument about the published
variants: the first parameter is only a tag SOURCE, and a handler need not
dispatch through it.)
import { tagSend } from '@llui/dom'
export function connect<S>(get, send, opts) {
return {
trigger: {
onClick: tagSend(send, ['open'], () => send({ type: 'open' })),
},
}
}
function tagSend<M extends { type: string }, F extends (...args: never[]) => unknown>(send: VariantTaggable<M>, libraryVariants: readonly M['type'][], fn: F): F
text()
function text(value: Reactive<string | number>): Mountable
title()
Set the document <title>. Reactive when given a signal. Last writer in the
tree (deepest layout/page) wins; restored on unmount. Combine with
{@link titleTemplate}.
function title(value: HeadValue<string>): Mountable
titleTemplate()
A %s template the active {@link title} is interpolated into — e.g.
titleTemplate('%s · LLui') + title('Docs') → Docs · LLui. Applies only
while a title is set.
function titleTemplate(value: HeadValue<string>): Mountable
unsafeHtml()
Render a raw HTML string as live DOM nodes (escape hatch for pre-rendered
markup — markdown, syntax highlighting). Reactive on a Signal<string>; a
plain string renders once. The HTML is inserted as-is — the caller owns
trust/sanitization.
function unsafeHtml(value: Reactive<string>): Mountable
useContext()
Read the nearest provided value for context, or its default. Outside a
signal build (e.g. a unit test calling connect() directly) no provider can
exist, so the default is returned rather than throwing.
function useContext<T>(context: Context<T>): T
virtualEach()
Virtualized keyed list — only the rows in the scroll viewport (+overscan)
exist in the DOM. items is a signal handle (like each); the render callback
receives per-row item + index signal handles. itemHeight is either a
uniform number (O(1) windowing) or a per-item (item, index) => number for
variable-height rows (cumulative offsets via a prefix sum, rebuilt when items
changes). Heights come from the data — measured/auto heights are not supported.
function virtualEach<T>(opts: {
items: Signal<readonly T[]>
key: (item: T) => string | number
itemHeight: number | ((item: T, index: number) => number)
containerHeight: number
overscan?: number
class?: string
render: (item: Signal<T>, index: Signal<number>) => Renderable
}): Mountable
Types
AttrValue
export type AttrValue = Reactive<string | number | boolean | null | undefined>
BindingSpec
A reactive binding: the dependency paths it reads + an accessor (produce)
and a commit that applies the value. This is the compiler transform's output
target, and the contract a {@link DirectRow} (compiled each row) supplies.
A spec IS a {@link SignalBinding} plus its build-time metadata — the scope
stores specs as-is (see scopeFromSpecs), so its intersection ADDS metadata to
the runtime contract rather than restating it; produce/commit/structural
have exactly one declaration, in runtime.ts.
export type BindingSpec = SignalBinding & {
deps: readonly string[]
// Root discriminant for row rebasing — set from the ORIGIN handle, so row
// locality never depends on string-inferring `item`/`index`/`state` prefixes
// (which collide with a component-state field literally named that). `true` ⇒
// the produce reads the COMPONENT state (rebase to `ctx.state` inside a row);
// `false` ⇒ it already reads the row ctx (an item/index handle, or an
// already-rebased spec — leave it). `undefined` ⇒ a compiler-emitted spec with
// no handle origin: fall back to the legacy `isRowLocalDep` string inference
// (compiled rows use the `item.*`/`state.*` ctx convention, so this is sound).
componentRooted?: boolean
}
ChildNode
A child slot: a lazy Mountable (everything LLui builds — elements, text, and
structural primitives — is a Mountable, materialized at placement), or a bare
string/number coerced to a static text node at append time (so div(['hi', 42])
works without an explicit text(...) — the same coercion every mainstream framework
does). There is no bare Node here: a node lives in one place, so exposing one would
reintroduce the silent double-placement footgun. Wrap raw DOM via foreign.
export type ChildNode = Mountable | string | number
CompilerMetaField
The descriptive field names of {@link COMPILER_META_KEYS}.
export type CompilerMetaField = keyof typeof COMPILER_META_KEYS
CompilerMetaKey
The literal property keys the compiler emits into the bundle.
export type CompilerMetaKey = (typeof COMPILER_META_KEYS)[CompilerMetaField]
ElProps
Props for an element helper. Well-known on* handlers (see {@link ElEventMap})
get their precise DOM event type, so onClick: (e) => e.clientX infers
e: MouseEvent with no annotation. Every other key — attributes, data-*,
aria-*, signals, and less-common events — is an {@link AttrValue} or a
loosely-typed handler via the index signature, which also lets connect()
part bags (with their own pre-typed handlers) spread in cleanly.
The handler index falls back to any ON PURPOSE: a stricter index type would
be a supertype of the precise on* handlers and reject them (function params
are contravariant), so the precise types live in the mapped half and the
index stays permissive.
export type ElProps = {
[K in keyof ElEventMap]?: (ev: ElEventMap[K]) => void
} & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: AttrValue | ((ev: any) => void) | undefined
}
EventHandler
export type EventHandler = (ev: Event) => void
HeadTarget
Where a head entry lands: a <head> element, an attribute on <html>/<body>,
or the (template-composed) <title>.
export type HeadTarget =
| { kind: 'element'; tag: string }
| { kind: 'attr'; on: 'html' | 'body'; name: string }
| { kind: 'title' }
| { kind: 'titleTemplate' }
HeadValue
A head value: a plain value (committed once) or a Signal (committed on
mount and on every change). Mirrors how foreign accepts handles-or-values.
export type HeadValue<T> = T | Signal<T>
MountTarget
Where a mountSignal call attaches its built nodes. A container element
(the common case — append, or replace its children on hydration) OR an
anchor comment, for adapters like @llui/vike that mount a nested layer as
siblings of a slot anchor without owning the parent element. The owned region
is bracketed by the anchor and a synthesized end sentinel; dispose() removes
exactly that region (leaving the anchor + outer siblings intact).
export type MountTarget =
| { container: Element; mode?: 'append' | 'replace' }
// `mode: 'replace'` (hydration) first removes any existing server region
// between the anchor and the next `llui-mount-end` sentinel, then mounts fresh
// — mirroring container hydration's atomic swap (no claim of server nodes).
| { anchor: Comment; mode?: 'append' | 'replace' }
PathValue
Resolve the value type at a dot-separated path of T, bubbling
nullability: once any segment introduces null/undefined (optional field,
nullable field, array index), it carries through to the result.
export type PathValue<T, S extends string> = [Extract<T, null | undefined>] extends [never]
? S extends `${infer Head}.${infer Tail}`
? PathValue<GetKey<T, Head>, Tail>
: GetKey<T, S>
: PathValue<NonNullable<T>, S> | Extract<T, null | undefined>
PropValue
export type PropValue = string | number | boolean | null | Reactive | EventHandler
Reactive
A reactive value in a slot: a signal of T, or a plain T.
export type Reactive<T> = Signal<T> | T
RegistryMethod
Registry-level pseudo-methods. They are NOT members of LluiDebugAPI — they
operate on the registry rather than on one component — but they travel the
same relay channel, so every relay has to recognize them.
export type RegistryMethod = '__listComponents' | '__selectComponent'
Renderable
The result of a render callback / view: lazy Mountables, materialized at
placement by populate/runBuild.
export type Renderable = readonly Mountable[]
RowFactory
Builds a fresh {@link DirectRow} (new nodes + binding closures) per row.
getCtx exposes the row's LIVE { item, state, index } ctx (the same box the
binding produce(ctx) reads), so a row's event-handler closures can read the
current row item at event time — onClick: () => send({ type: 'toggle', id: getCtx().item.id }) — the direct-path analogue of the render path's
pathHandle(getCtx, 'item'). Rows with no item-referencing handlers ignore it.
export type RowFactory = (doc: SignalDoc, getCtx: () => RowCtx<unknown>) => DirectRow
Send
export type Send<M> = (msg: M) => void
SerializableCollectOptions
The options that survive serialization into a page expression — components
is a live object graph and cannot cross the CDP boundary.
export type SerializableCollectOptions = Omit<DebugCollectOptions, 'components'>
ServerDoc
A server DOM document: the node-factory subset the build needs. A DomEnv
from @llui/dom/ssr/jsdom or @llui/dom/ssr/linkedom satisfies it.
export type ServerDoc = SignalDoc
StateHandle
The bag's state is a Signal<S> so authored handler code reads it the same
way as the view (state.at('x').peek()). At runtime it's a read handle: .at
narrows, .peek reads the current value; .map is a view-build-time concept
and throws if reached on the handle.
export type StateHandle<S> = Signal<S>
ValidPath
The union of all valid dot-separated paths of T — both intermediate
(object) paths and leaf paths. Arrays contribute ${number} indices,
${number}.<sub> nested paths, and 'length'. Navigation descends through
nullable/optional fields (via NonNullable), bounded by {@link PathDepthBudget}.
export type ValidPath<T, D extends number = PathDepthBudget> = T extends null | undefined
? ValidPath<NonNullable<T>, D>
: T extends readonly (infer U)[]
? D extends 0
? `${number}` | 'length'
: `${number}` | `${number}.${ValidPath<U, DecrDepth[D]>}` | 'length'
: T extends object
? {
[K in keyof T & string]: NonNullable<T[K]> extends object
? D extends 0
? K
: K | `${K}.${ValidPath<NonNullable<T[K]>, DecrDepth[D]>}`
: K
}[keyof T & string]
: never
Interfaces
BaseAttrs
Attributes accepted by {@link base}.
export interface BaseAttrs {
href?: HeadValue<string>
target?: HeadValue<string>
}
BindingDebugInfo
export interface BindingDebugInfo {
index: number
mask: number
lastValue: unknown
kind: string
key: string | undefined
dead: boolean
perItem: boolean
}
BindingError
A binding-evaluation failure surfaced to a setOnBindingError hook. Shape
matches the agent's dispatch-envelope drain.errors entries.
export interface BindingError {
kind: string
key?: string
message: string
stack?: string
}
BindingLocation
export interface BindingLocation {
bindingIndex: number
kind: string
key: string | undefined
mask: number
lastValue: unknown
/** How the binding's node relates to the matched element. */
relation: 'self' | 'text-child' | 'comment-child'
}
CollectedHead
The serialized output of a server render's head collection.
export interface CollectedHead {
/** `<head>` element markup (title/meta/link/…), each marked `data-llui-head`. */
head: string
/** Attribute string for `<html …>` (leading space included), already escaped. */
htmlAttrs: string
/** Attribute string for `<body …>` (leading space included), already escaped. */
bodyAttrs: string
/** Dedup keys present in `head` (e.g. `title`, `meta:name=description`). Used by
* {@link mergeStaticHead} to strip colliding tags from a static `+Head.ts`. */
keys: readonly string[]
}
CollectHeadSink
A server-side {@link HeadSink} that collects entries and serializes them. Seed
it via {@link HEAD_SINK} before rendering, then call serialize(env).
export interface CollectHeadSink extends HeadSink {
serialize(doc: SignalDoc): CollectedHead
}
CompilerMetadata
The compiler-injected introspection metadata carried by a compiled
component({...}) literal. Every field is optional: a production build
without agent: true emits none of them, and componentMeta is dev-only.
Spelled with computed keys so the declared shape and the runtime read path cannot drift from {@link COMPILER_META_KEYS} — there is exactly one place a key name is written.
export interface CompilerMetadata {
/** discriminated-union schema of Msg ({ discriminant, variants }) */
readonly [COMPILER_META_KEYS.msgSchema]?: object
/** discriminated-union schema of Effect */
readonly [COMPILER_META_KEYS.effectSchema]?: object
/** state shape schema */
readonly [COMPILER_META_KEYS.stateSchema]?: object
/** per-message JSDoc annotations (intent, affordability, …) */
readonly [COMPILER_META_KEYS.msgAnnotations]?: Record<string, unknown>
/** stable hash of the schemas, for hot-reload schema-change detection */
readonly [COMPILER_META_KEYS.schemaHash]?: string
/** dev-only source location */
readonly [COMPILER_META_KEYS.componentMeta]?: { file: string; line: number }
}
ComponentBag
export interface ComponentBag<S, M> {
state: Signal<S>
send: (msg: M) => void
/** Coalesce a burst of `send`s into ONE reconcile (see the handle's `batch`).
* Reducers/effects still run per message; only the DOM commit is deferred to the
* outermost `batch` exit. Use it to drain a burst of dispatches (e.g. a stream
* frame) from a handler/subscription as a single re-render. */
batch: (fn: () => void) => void
}
ComponentInfo
export interface ComponentInfo {
name: string
file: string | null
line: number | null
/** Identifies which runtime mounted the component. */
runtime?: 'signal' | 'legacy'
}
ComponentInfoSnapshot
export interface ComponentInfoSnapshot {
/** Names of all currently mounted host components (registry keys). */
componentPath: string[]
/** Metadata for the first mounted component — the most likely "owning"
* component when a capture doesn't carry a precise scope. */
componentMeta: DebugComponentMeta | null
}
ComponentRegistryAccess
How a caller reaches the component registry and the "selected component"
pointer. In the page that pointer is globalThis.__lluiDebug
(globalRegistryAccess()); in the MCP server's direct mode it is the relay's
own attached API. Abstracting it is what lets both sides run ONE resolver.
export interface ComponentRegistryAccess {
/** The live registry, or undefined when nothing has mounted. */
registry(): Record<string, LluiDebugAPI> | undefined
/** The currently selected API, or undefined when nothing is selected. */
active(): LluiDebugAPI | undefined
/** Point the selection at `api`. */
setActive(api: LluiDebugAPI): void
}
Context
export interface Context<T> {
readonly id: symbol
readonly default: T
}
CoverageSnapshot
Per-variant Msg coverage tracker — dev-only.
Records each dispatched message's discriminant (or <non-discriminant>
for objects missing a type field) along with the message index it
fired at. Consumed by the llui_coverage MCP tool to surface untested
Msg variants: any variant declared in the compiled msg schema that
never fired in the current session shows up in neverFired.
Zero cost in production: installSignalDebug is the only caller, and it
never runs in prod builds. Hot path is one optional-chain read per
dispatched message (ci._coverage?.record(...)).
export interface CoverageSnapshot {
fired: Record<string, { count: number; lastIndex: number }>
neverFired: string[]
}
DebugCollectOptions
export interface DebugCollectOptions {
/** Override the registry lookup. Tests inject a stub map; the page path and
* the HUD both leave this unset and read `globalThis.__lluiComponents`. */
components?: Record<string, TelemetrySource>
/** Cap on `messageLog` entries; default 50. */
messageLimit?: number
/** Cap on `effects.recent` entries; default 50. */
effectLimit?: number
}
DebugComponentMeta
export interface DebugComponentMeta {
file: string
line: number
name: string
}
DebugMessageLogEntry
export interface DebugMessageLogEntry {
ts: string
component: string
msg: unknown
}
DebugPendingEffectEntry
export interface DebugPendingEffectEntry {
id: string
component: string
effect: unknown
sinceMs: number
}
DebugRecentEffectEntry
export interface DebugRecentEffectEntry {
ts: string
component: string
effect: { type: string | null; id: string }
outcome: 'ok' | 'error' | 'cancelled'
}
DebugSnapshot
A point-in-time telemetry snapshot of every mounted component. Structurally a
NoteBody subset (@llui/notes-format) — the notebook is its main consumer —
but declared here so the runtime keeps its zero-dependency root position.
export interface DebugSnapshot {
stateSnapshot?: Record<string, unknown>
messageLog?: DebugMessageLogEntry[]
effects?: {
pending: DebugPendingEffectEntry[]
recent: DebugRecentEffectEntry[]
}
}
DirectRow
A compiler-emitted (or hand-written) direct each row: real DOM nodes built
with direct ops + binding specs wired by DIRECT node reference — bypassing the
authoring-helper / Mountable / populate / pathHandle machinery the
generic row path runs per row. The factory runs per row under the build ctx;
each spec's produce(ctx) reads the row ctx ({ item, state, index }) and its
commit writes straight to the located node. See
docs/proposals/v2-compiler/compiled-row-construction.md.
export interface DirectRow {
nodes: Node[]
bindings: readonly BindingSpec[]
}
DisposerEvent
Dev-only disposer log entry, emitted once per disposeLifetime call
when the owning component instance has an _disposerLog ring buffer
installed by installSignalDebug.
cause is set by the structural primitive (each / branch / child)
immediately before calling disposeLifetime. When no cause was
explicitly set, disposeLifetime falls back to 'component-unmount'.
'app-unmount' is reserved for the top-level mountApp teardown.
Used by the llui_disposer_log MCP tool to diagnose leaks on
structural transitions (e.g., branch swap that fails to release a
subscription registered in the old arm).
export interface DisposerEvent {
scopeId: string
cause:
| 'branch-swap'
| 'each-remove'
| 'show-hide'
| 'scope-rebuild'
| 'child-unmount'
| 'app-unmount'
| 'component-unmount'
timestamp: number
}
DomEnv
Minimal DOM surface that @llui/dom's internals depend on. Passed to
mountApp / hydrateSignalApp / renderToString as a context object so
the runtime never reaches for globalThis.document directly.
Why an injected shape instead of a global shim:
- Bundler-friendly. A Cloudflare Worker that imports
@llui/dom/ssr/linkedomreaches only linkedom via its module graph. Noawait import('jsdom')appears in reachable source, so rollup doesn't inline the 9 MiB jsdom bundle. - Concurrency-safe. Two
renderToStringcalls can pass different envs; no process-level singleton to collide on. - Strict-isolate safe. No
globalThis[key] = ...mutation — Cloudflare workerd and Deno strict modes forbid it.
The surface is deliberately narrow: exactly the methods and
constructors the runtime touches. Grep document\. /
instanceof (HTMLElement|Element|...) inside @llui/dom/src for
the exhaustive set.
export interface DomEnv {
// ── Factories ────────────────────────────────────────────────────
createElement(tag: string): Element
createElementNS(ns: string, tag: string): Element
createTextNode(text: string): Text
createComment(text: string): Comment
createDocumentFragment(): DocumentFragment
/**
* Used by `each()`'s fast clear/bulk-remove paths to delete a range
* of siblings in one call. SSR adapters that don't need those paths
* (jsdom + linkedom both do) can stub — the runtime tolerates a
* missing range during SSR render, which never hits the bulk paths.
*/
createRange(): Range
// ── Node / element constructors ─────────────────────────────────
// Exposed for `instanceof` checks in binding targeting + for any
// rare site that needs to construct a node type directly.
readonly Element: typeof Element
readonly Node: typeof Node
readonly Text: typeof Text
readonly Comment: typeof Comment
readonly DocumentFragment: typeof DocumentFragment
readonly HTMLElement: typeof HTMLElement
readonly HTMLTemplateElement: typeof HTMLTemplateElement
readonly ShadowRoot: typeof ShadowRoot
// ── Event constructor ───────────────────────────────────────────
readonly MouseEvent: typeof MouseEvent
/**
* Parse an HTML fragment string into a `DocumentFragment`. Used by
* `unsafeHtml()`. Browsers and jsdom parse via template-element
* innerHTML; linkedom has its own fragment parser. Adapter chooses
* the right mechanism.
*/
parseHtmlFragment(html: string): DocumentFragment
/**
* Resolve a CSS selector against the env's root document. Used by
* `portal()` to locate its target when `opts.target` is a string.
*
* Returns `null` when the selector doesn't match — portal callers
* treat a null target as a no-op (render nothing), so adapters on
* runtimes where no real document exists (detached linkedom, empty
* shadow root, etc.) can safely return `null` here.
*
* Required — making this mandatory on the interface means a custom
* env that forgets to wire up selector resolution fails compile
* instead of silently falling back to `globalThis.document` at
* render time (which would crash under Cloudflare Workers + other
* strict-isolate runtimes). The three first-party envs
* (`browserEnv`, `jsdomEnv`, `linkedomEnv`) all implement it.
*/
querySelector(selector: string): Element | null
/**
* @internal Lets hot-path code (e.g. `el-split.ts`'s template-clone)
* skip env indirection when the env wraps the browser globals. Only
* set by `browserEnv()`.
*/
readonly isBrowser?: boolean
}
EachDiff
Per-each-block reconciliation diff, recorded once per update that
mutates an each() block's key set. Dev-only — populated when
installSignalDebug has initialized an _eachDiffLog on the instance.
updateIndex correlates with the message-history index recorded by
devtools.ts so tools can join diffs back to the message that caused
them. eachSiteId identifies the each() call site stably across
updates (currently derived from the block's index in the instance's
structuralBlocks array at creation time).
export interface EachDiff {
/**
* Message-history index at the time the diff was emitted. When messages are
* batched (multiple send() calls coalescing into one microtask), this is
* the index of the LAST message in the batch — not necessarily the one that
* caused the structural change. For per-message correlation, use
* getMessageHistory with this index as an upper bound.
*/
updateIndex: number
/**
* Stable-ish identifier for the each() call site. Currently derived from the
* position of the block in `ComponentInstance.structuralBlocks` at the moment
* of registration, formatted as `each#${N}`.
*
* Caveats for consumers:
* - The counter includes ALL structural blocks (branches, shows, portals,
* eaches), not just eaches. So `each#3` means "the 4th structural block",
* not "the 4th each".
* - Blocks registered inside a `branch` arm that switches away are spliced
* out; a subsequent each registration can reuse the same N.
* - Across HMR reloads the ID may drift if the view's structural-block
* order changed.
*
* For precise correlation across updates, pair with `updateIndex` and the
* enclosing component's state at that index (retrievable via
* getMessageHistory).
*/
eachSiteId: string
added: string[]
removed: string[]
moved: Array<{ key: string; from: number; to: number }>
reused: string[]
}
EachSource
Items source for signalEach: an accessor reading the array out of the
component state, plus the dep paths the list depends on — the items path AND
any component-state paths the rows read (so the list reconciles on either).
export interface EachSource<T> {
/** Read the list out of the state the reconcile was handed.
*
* The return type is DELIBERATELY nullable even though every authoring entry
* point is typed `Signal<readonly T[]>`: the accessor is a path walk, and
* `mask.ts`'s `resolveSegs` is explicitly undefined-safe, so `state.at('items')`
* over an absent/late-arriving path produces `undefined` rather than throwing.
* The reconcile totals that with {@link createItemsResolver} — see #165. */
items: (state: unknown) => readonly T[] | null | undefined
deps: readonly string[]
/** See {@link BindingSpec.componentRooted}: `true` when the items accessor reads
* the COMPONENT state (so a nested each reads `ctx.state`, not the enclosing row
* ctx). Set by the authoring layer from the items handle; unbranded → inference. */
componentRooted?: boolean
}
EffectApi
export interface EffectApi<S, M> {
send: (msg: M) => void
state: Signal<S>
/** Coalesce a burst of `send`s into ONE reconcile (see {@link ComponentBag.batch}). */
batch: (fn: () => void) => void
/** This mount's lifecycle {@link AbortSignal}. Aborted exactly once, on THIS
* mount's `dispose()`. Each mount of a definition owns a distinct signal, so an
* effect handler can key per-mount resources off it — pass it to `fetch`, or
* `signal.addEventListener('abort', cleanup)` — and disposing one mount never
* aborts a concurrent mount of the same definition. Prefer this over a
* definition-level controller for anything whose lifetime is the mount's. */
signal: AbortSignal
}
EffectMatch
Match predicate for the mock registry. All provided fields must match for the mock to fire:
type: exact-match against the effect'stypediscriminant.payloadPath: dotted path into the effect object (e.g.'url'or'body.key'). When present withoutpayloadEquals, presence of the path is sufficient.payloadEquals: strict (===) equality check atpayloadPath.
An empty match (no fields) matches every effect — callers should
set at least type to avoid accidental catch-all.
export interface EffectMatch {
type?: string
payloadPath?: string
payloadEquals?: unknown
}
EffectTimelineEntry
export interface EffectTimelineEntry {
effectId: string
type: string
phase: 'dispatched' | 'in-flight' | 'resolved' | 'resolved-mocked' | 'cancelled'
timestamp: number
/** Populated on `resolved` / `resolved-mocked` / `cancelled` entries; undefined on open phases. */
durationMs?: number
}
ElementReport
export interface ElementReport {
selector: string
tagName: string
attributes: Record<string, string>
classes: string[]
dataset: Record<string, string>
text: string
computed: {
display: string
visibility: string
position: string
width: number
height: number
}
boundingBox: { x: number; y: number; width: number; height: number }
bindings: Array<{
bindingIndex: number
kind: string
mask: number
lastValue: unknown
relation: 'self' | 'text-child' | 'comment-child'
}>
}
ForeignSpec
export interface ForeignSpec<Inst, State extends Record<string, SignalSpec<unknown>>> {
/** host element tag (default 'div') */
tag?: string
/** declared reactive inputs — materialized to LiveSignals for `mount` */
state?: State
/** build the imperative instance into the host element */
mount: (args: {
el: Element
state: { [K in keyof State]: LiveSignal<State[K] extends SignalSpec<infer T> ? T : unknown> }
}) => Inst
/** tear down the instance (runs on the owning component's dispose) */
unmount?: (instance: Inst) => void
}
HeadController
A single registered writer's handle: the binding sets its value(s) here;
teardown releases it.
export interface HeadController {
set(attrs: Record<string, unknown>, text?: string): void
release(): void
}
HeadSink
The coordinator a head primitive commits through. One per app document (client) or one per render (server collector).
export interface HeadSink {
register(key: string, target: HeadTarget): HeadController
}
HydrationDivergence
export interface HydrationDivergence {
path: string
kind: 'attribute' | 'text' | 'structural'
server: unknown
client: unknown
}
LifetimeNode
Lifetime-tree node for the debug/agent surface. A serialized snapshot of the live scope tree — the signal devtools surface and MCP tools read this shape to render scope lifecycle.
export interface LifetimeNode {
scopeId: string
kind: 'root' | 'show' | 'each' | 'branch' | 'scope' | 'child' | 'portal' | 'foreign'
active: boolean
children: LifetimeNode[]
}
LinkAttrs
Attributes accepted by {@link link}.
export interface LinkAttrs {
rel?: string
href?: HeadValue<string>
[attr: string]: HeadValue<string> | undefined
}
ListComponentsResult
export interface ListComponentsResult {
components: string[]
active: string | null
}
LiveSignal
A materialized signal handed to imperative code at the foreign boundary.
Minimal on purpose — all derivation stays in the declarative state:
declaration, so this is a read+subscribe handle only.
peek()— one-shot, non-reactive read (same verb as {@link Signal}).bind(cb)— firescbsynchronously with the current value, then on every change; returns an unsubscribe. Mount-timebinds auto-dispose on unmount.
Deliberately no on (event-listener vocabulary trains a redundant
peek-then-subscribe), no change-only mode, and no at/map/derived.
export interface LiveSignal<T> {
peek(): T
bind(cb: (value: T) => void): () => void
}
LluiDebugAPI
The relay-callable debug surface of a mounted LLui component.
Required methods are implemented by every runtime (and by
installSignalDebug). Optional methods are binding/scope/effect
introspection that only the legacy runtime provides — callers must
feature-detect and degrade when they are absent.
export interface LluiDebugAPI {
// ── Core (always implemented) ──────────────────────────────────
getState(): unknown
send(msg: unknown): void
flush(): void
getMessageHistory(opts?: { since?: number; limit?: number }): MessageRecord[]
evalUpdate(msg: unknown): { state: unknown; effects: unknown[] }
exportTrace(): {
lluiTrace: 1
component: string
generatedBy: string
timestamp: string
entries: Array<{ msg: unknown; expectedState: unknown; expectedEffects: unknown[] }>
}
clearLog(): void
validateMessage(msg: unknown): ValidationError[] | null
searchState(query: string): unknown
getMessageSchema(): MessageSchemaInfo | object | null
getStateSchema(): object | null
getEffectSchema(): object | null
getComponentInfo(): ComponentInfo
snapshotState(): unknown
restoreState(snap: unknown): void
// ── Binding / scope introspection (legacy-only; optional) ──────
getBindings?(): BindingDebugInfo[]
whyDidUpdate?(bindingIndex: number): UpdateExplanation
getMaskLegend?(): Record<string, number> | null
decodeMask?(mask: number): string[]
getBindingsFor?(selector: string): BindingLocation[]
getBindingGraph?(): Array<{ statePath: string; bindingIndices: number[] }>
getBindingSource?(bindingIndex: number): { file: string; line: number; column: number } | null
forceRerender?(): { changedBindings: number[] }
getEachDiff?(sinceIndex?: number): EachDiff[]
getScopeTree?(opts?: { depth?: number; scopeId?: string }): LifetimeNode
getDisposerLog?(limit?: number): DisposerEvent[]
// ── DOM inspection (legacy-only; optional) ─────────────────────
inspectElement?(selector: string): ElementReport | null
getRenderedHtml?(selector?: string, maxlength?: number): string
dispatchDomEvent?(
selector: string,
type: string,
init?: EventInit,
): {
dispatched: boolean
messagesProducedIndices: number[]
resultingState: unknown | null
}
getFocus?(): {
selector: string | null
tagName: string | null
selectionStart: number | null
selectionEnd: number | null
}
getHydrationReport?(): HydrationDivergence[]
// ── Effect introspection (legacy-only; optional) ───────────────
getPendingEffects?(): PendingEffect[]
getEffectTimeline?(limit?: number): EffectTimelineEntry[]
mockEffect?(
match: EffectMatch,
response: unknown,
opts?: { persist?: boolean },
): { mockId: string }
resolveEffect?(effectId: string, response: unknown): { resolved: boolean }
// ── Time-travel / coverage / eval (legacy-only; optional) ──────
stepBack?(n: number, mode: 'pure' | 'live'): { state: unknown; rewindDepth: number }
getCoverage?(): CoverageSnapshot
getCompiledSource?(viewFn?: string): { pre: string; post: string } | null
getMsgMaskMap?(): Record<string, number> | null
evalInPage?(code: string): {
result: unknown | { error: string }
sideEffects: {
stateChanged: StateDiff | null
newHistoryEntries: number
newPendingEffects: PendingEffect[]
dirtyBindingIndices: number[]
}
}
}
MessageRecord
export interface MessageRecord {
index: number
timestamp: number
msg: unknown
stateBefore: unknown
stateAfter: unknown
effects: unknown[]
/** Present only on the legacy runtime, which computes a dirty mask per update. */
dirtyMask?: number
}
MessageSchemaInfo
export interface MessageSchemaInfo {
discriminant: string
variants: Record<string, Record<string, unknown>>
}
MetaAttrs
Attributes accepted by {@link meta}. Identity attrs (name/property/
httpEquiv/charset) should be static so the entry can dedup.
export interface MetaAttrs {
name?: string
property?: string
httpEquiv?: string
charset?: string
content?: HeadValue<string>
[attr: string]: HeadValue<string> | undefined
}
Mountable
A lazy node description: mount() builds the live node (and registers its
bindings into the active build) at placement time. Everything LLui builds —
elements, text, and structural primitives — is a Mountable, materialized
where it is placed (see populate/runBuild).
export interface Mountable {
readonly [MOUNTABLE]: true
mount(): Node
}
MountSignalOptions
Options for mountSignalComponent.
export interface MountSignalOptions<S> {
/** Hydrate over server-rendered DOM instead of a fresh mount: seed the loop
* with `serverState` (what the server rendered with) and atomically REPLACE the
* server HTML with the freshly-built client tree. init()'s effects are skipped
* by default (the server pass already ran them) — opt back in with
* `runInitEffects` for init()s gated to no-op on the server. */
hydrate?: { serverState: S; runInitEffects?: boolean }
/** Seed state to mount with instead of `init()`'s result (adapters that derive
* the seed externally, e.g. per-route data). init() still runs so its effects
* are captured; only the returned state is overridden. Ignored when hydrating
* (use `hydrate.serverState` there). */
initialState?: S
/** Context values to expose at the root of this build (see `runBuild`'s
* `seedContexts`). `@llui/vike` replays a layout's in-scope contexts here so a
* nested page reads providers that live above its slot in a SEPARATE build. */
contexts?: ReadonlyMap<symbol, unknown>
/** Commit scheduling. `'sync'` (the default) commits the DOM + notifies
* subscribers inside every top-level `send` — the synchronous contract.
* `'raf'` is the OPT-IN streaming/burst fast path: reducers and effects
* still run synchronously per send (state and `getState()` advance
* immediately — the data contract holds), but the DOM commit + subscriber
* notification coalesce to ONE reconcile per animation frame (microtask
* fallback where rAF doesn't exist: SSR, plain jsdom, the headless agent).
* The DOM therefore lags state by up to a frame; `handle.flush()` forces
* the pending commit synchronously (tests, the agent protocol, a
* read-after-write). Measured on the ticker suite's 1k-send burst:
* 14.1ms per-send vs 5.9ms coalesced (hand-written-vanilla parity). */
scheduler?: 'sync' | 'raf'
/** Register this component in the global devtools registry
* (`__lluiComponents` / `__lluiDebug`). Default `true` in dev. Set `false`
* for self-introspecting dev tooling (e.g. an in-app debug HUD authored with
* LLui) so it doesn't pollute the host app's component list that external
* tools — the MCP server, agent bridge, debug-collector — read. */
devtools?: boolean
}
PendingEffect
export interface PendingEffect {
id: string
type: string
dispatchedAt: number
status: 'queued' | 'in-flight'
payload: unknown
}
RowCtx
The per-row context a row scope mounts on: its item plus the current
component state. Row bindings read ctx.item.* (dep item.*) and
ctx.state.* (dep state.*) — so a row can react to BOTH its own item and
the component state (e.g. a shared display mode).
export interface RowCtx<T> {
item: T
state: unknown
/** the row's current position (dep `index`) — for runtime `each` index handles */
index: number
}
ScriptAttrs
Attributes accepted by {@link script}.
export interface ScriptAttrs {
src?: HeadValue<string>
type?: HeadValue<string>
async?: HeadValue<boolean>
defer?: HeadValue<boolean>
id?: string
[attr: string]: HeadValue<string | boolean> | undefined
}
SelectComponentResult
export interface SelectComponentResult {
active: string
}
ShowCond
Condition source for signalShow / discriminant source for signalBranch: an
accessor plus its dep paths.
export interface ShowCond {
produce: (state: unknown) => unknown
deps: readonly string[]
/** See {@link BindingSpec.componentRooted}: `true` when the condition reads the
* COMPONENT state (so inside a row it's fed `ctx.state`, not the combined ctx).
* Set by the authoring layer from the cond handle; unbranded → string inference. */
componentRooted?: boolean
}
Signal
A reactive view of a value of type T. Three methods, the entire reactive
vocabulary alongside derived:
at(path)— slice into a sub-signal via a statically-typed dot path.map(fn)— transform into a derived signal (single source).peek()— one-shot, non-reactive read (handlers / effects / lifecycle).
export interface Signal<T> {
/**
* Slice into a sub-signal via a statically-typed dot path
* (`state.at('user.profile.name')`). The path is validated and the result type
* resolved at compile time.
*
* **Depth limit.** `ValidPath<T>` enumerates the union of *every* valid dotted
* path of `T` (to validate the argument and power autocomplete). That union
* grows multiplicatively with the state's width × depth, so on a large /
* deeply-nested `T` it can exceed TypeScript's instantiation limit and surface
* as `TS2589: Type instantiation is excessively deep`. The cost comes from the
* *whole state shape*, not the single path you wrote. If you hit it, reach for
* `.map(s => s.deep.path)` instead — a `.map()` derive sidesteps path typing
* entirely (it reads the whole slice; the runtime still gates it correctly) and
* is the supported escape hatch for very deep paths.
*/
at<P extends ValidPath<T>>(path: P): Signal<PathValue<T, P>>
map<U>(fn: (value: T) => U): MappedSignal<U>
peek(): T
}
SignalComponentDef
export interface SignalComponentDef<S, M, E = never> extends CompilerMetadata {
/** optional component name (for the debug registry / agent identity) */
readonly name?: string
/** initial state, optionally with initial effects */
init: () => S | [S, E[]]
/** pure reducer; returns the next state, optionally with effects. A bare `S`
* (non-tuple) return is accepted for convenience. */
update: (state: S, msg: M) => [S, E[]] | S
/** build the view once; reactive reads are signal bindings (they don't close
* over `state`). The bag's `state` handle is for handlers/effects. */
view: (bag: ComponentBag<S, M>) => Renderable
/** handle an effect; may return a cleanup function */
onEffect?: (effect: E, api: EffectApi<S, M>) => void | (() => void)
// Compiler-injected introspection metadata arrives via `CompilerMetadata` —
// present only in dev / agent builds, keyed by the compiler↔runtime ABI in
// `compiler-keys.ts`. Read by the agent-client pairing path and the debug
// surface, both of which can be in a different chunk than the def.
}
SignalComponentHandle
export interface SignalComponentHandle<S, M> {
send(msg: M): void
/** Coalesce a burst of `send`s into ONE reconcile + commit. Every message's
* reducer still runs in order (state advances message-by-message, effects fire
* per message), but the DOM reconcile + subscriber notification are deferred to
* a single pass against the FINAL state when the outermost `batch` returns.
* For N synchronous sends this turns N reconciles into 1 — the streaming /
* bulk-dispatch fast path (e.g. draining a websocket frame of ticks). State is
* applied by the time `batch` returns, so the synchronous-`send` contract holds
* at the batch boundary. Nested `batch` calls flush only at the outermost exit. */
batch(fn: () => void): void
getState(): S
/** no-op: signal `send` applies updates synchronously (kept for harness/agent
* parity with the legacy handle). */
flush(): void
/** run all pending effect cleanups (subscriptions etc.) */
dispose(): void
/** Register a listener called synchronously after every update cycle that
* changes state, with the new state. Returns an unsubscribe. No-op after
* dispose. Backs the agent protocol's state-update frames.
*
* Each listener is ISOLATED: a throw is reported (console +
* {@link SignalComponentHandle.setOnBindingError}) and then contained — the
* listeners after it still run, the message's effects are still dispatched,
* and nothing reaches the caller of `send`. */
subscribe(listener: (state: S) => void): () => void
/** Run the reducer in isolation against the current state — `{state, effects}`
* with no commit/flush/effect dispatch. Backs the agent's `would_dispatch`. */
runReducer(msg: M): { state: S; effects: unknown[] } | null
/** Snapshot the Msg variants dispatchable from currently-rendered UI (live
* `tagSend` registrations). Backs the agent's `list_actions`. */
getBindingDescriptors(): Array<{ variant: string }>
/** Hot-swap the reducer (and optionally onEffect) without rebuilding the DOM —
* the HMR escape hatch for pure update.ts edits. State-type erased at this
* boundary (`unknown`) so the handle stays assignable across state types. */
swapUpdate(
newUpdate: (state: unknown, msg: unknown) => [unknown, unknown[]] | unknown,
newOnEffect?: unknown,
): void
/** Install a hook called when a binding accessor throws during the update
* cycle; the runtime leaves the binding's DOM at its prior value and continues
* with siblings. Also called (with `kind: 'subscriber'`) for a
* {@link SignalComponentHandle.subscribe} listener that throws during the
* post-commit notify. Backs the agent's dispatch-envelope `drain.errors`. */
setOnBindingError(hook: ((e: BindingError) => void) | null): void
}
SignalComponentSpec
export interface SignalComponentSpec<S, M, E = never> {
/** optional component name (debug registry / agent identity) */
name?: string
init: () => S | [S, E[]]
update: (state: S, msg: M) => [S, E[]] | S
view: (bag: SignalViewBag<S, M>) => Renderable
onEffect?: (
effect: E,
api: {
send: Send<M>
state: Signal<S>
batch: (fn: () => void) => void
/** This mount's lifecycle {@link AbortSignal} — aborted on dispose. */
signal: AbortSignal
},
) => void | (() => void)
}
SignalDebugHooks
Everything the signal debug API needs from a mounted component. Supplied by mountSignalComponent; keeps this module decoupled from the mount internals.
export interface SignalDebugHooks {
name: string
getState: () => unknown
/** replace state and re-render (restore / time-travel) */
setState: (s: unknown) => void
send: (msg: unknown) => void
/** pure reducer, normalized to [state, effects] (for evalUpdate / dry-run) */
pureUpdate: (s: unknown, msg: unknown) => [unknown, unknown[]]
/** captured message log (newest last); installSignalDebug reads it live */
history: readonly SignalMessageRecord[]
clearHistory: () => void
msgSchema?: object
stateSchema?: object
effectSchema?: object
componentMeta?: { file: string; line: number }
}
SignalHandle
A runtime Signal: the read surface PLUS the binding info needed to build a
reactive slot at runtime (view-helper composition).
export interface SignalHandle<T> extends Signal<T> {
readonly [SIGNAL]: true
/** resolve the value from the binding's state (component or row ctx) */
readonly produce: (state: unknown) => T
/** dependency paths into the binding's state */
readonly deps: readonly string[]
/** Root discriminant for row rebasing. `true` ⇒ this handle reads the ROW ctx
* (an `item`/`index` handle from `rowHandle`, or a row-aware `derived`); `false`
* (or absent) ⇒ it reads the COMPONENT state and must be rebased to `ctx.state`
* when placed inside an `each` row. Set at construction from the getter's origin,
* so locality never depends on string-inferring a `state`/`item`/`index` field
* name (which collides with a component field literally named that). */
readonly rowLocal?: boolean
}
SignalLazyOptions
export interface SignalLazyOptions<LS = unknown, LM = unknown, LE = unknown> {
/** async loader — typically `() => import('./Chart').then(m => m.default)`. The
* loaded component's S/M/E are inferred, so `initialState` is typed and no cast
* is needed at the call site. */
loader: () => Promise<SignalComponentDef<LS, LM, LE>>
/** nodes rendered (reactively, in the current build) while loading */
fallback: () => Renderable
/** nodes rendered if the loader rejects (nothing if omitted) */
error?: (err: Error) => Renderable
/** seed state for the loaded component, overriding its `init()` result */
initialState?: LS
}
SignalMessageRecord
export interface SignalMessageRecord {
index: number
timestamp: number
msg: unknown
stateBefore: unknown
stateAfter: unknown
effects: unknown[]
}
SignalMount
export interface SignalMount {
/** apply a new state; only bindings whose deps changed re-run and commit. */
update(next: unknown): void
/** run teardowns (foreign unmount, subscriptions). */
dispose(): void
/** live agent-affordance variants (tagged-send handlers currently mounted). */
getDescriptors(): Array<{ variant: string }>
}
SignalSpec
A declared reactive input to foreign: an accessor + its dep paths.
export interface SignalSpec<T> {
produce: (state: unknown) => T
deps: readonly string[]
}
SignalViewBag
export interface SignalViewBag<S, M> {
state: Signal<S>
send: Send<M>
/** Coalesce a burst of `send`s into ONE reconcile (see the handle's `batch`). */
batch: (fn: () => void) => void
}
StateDiff
export interface StateDiff {
added: Record<string, unknown>
removed: Record<string, unknown>
changed: Record<string, { from: unknown; to: unknown }>
}
StyleAttrs
Attributes accepted by {@link style} / {@link script}. A static id keys the
tag for dedup + SSR-hydration adoption; without one the tag is anonymous (no
dedup, keyed by stable construction order).
export interface StyleAttrs {
id?: string
media?: HeadValue<string>
[attr: string]: HeadValue<string> | undefined
}
TeaDriver
A view-less LLui TEA runtime. It uses the same queue, reentrancy guard, effect-frame ordering, and batching machinery as a mounted component.
export interface TeaDriver<S, M> {
/**
* Dispatch a message synchronously. Before this returns, the reducer queue,
* settled-state notification, and any synchronous effect-driven sends have
* all reached quiescence.
*/
send(msg: M): void
/**
* Run a burst of sends and notify {@link TeaDriverOptions.onStateChange} once
* with the outermost batch's settled state. Reducers and effects still run in
* message order, and nested batches join the outer batch.
*/
batch(fn: () => void): void
/** Read the current state, including updates made earlier in an open batch. */
getState(): S
/**
* Flush an owed commit. The public view-less driver is synchronous, so this is
* normally a no-op; it exists for handle parity with mounted drivers.
*/
flush(): void
/**
* Abort the lifecycle signal, stop accepting messages, and run effect
* cleanups once in registration order. Later sends and batches are ignored.
*/
dispose(): void
}
TeaDriverOptions
Optional observers for a {@link TeaDriver}. With onTransition absent, the
hot reducer path does not allocate a transition record.
export interface TeaDriverOptions<S, M, E> {
/**
* Called synchronously after every reducer call, including calls made inside
* a batch and calls caused by an effect. It runs after state advances and
* effects are collected, but before the settled-state notification and effect
* dispatch. Throwing aborts that settle round: the state already returned by
* the reducer remains current, while that round's effects are dropped.
*/
onTransition?: (transition: TeaTransition<S, M, E>) => void
/**
* Called synchronously when a changed state settles: once for a plain send,
* once at the outermost batch exit, and again for each effect-driven settle.
* It runs before that settle's effects. Throwing aborts the round and drops
* those effects, matching a mounted commit that throws.
*/
onStateChange?: (state: S) => void
}
TeaEffectApi
The capabilities available while a {@link TeaProgram} interprets an effect.
export interface TeaEffectApi<S, M> {
/** Queue a message in the current drain; effect-driven sends are reentrancy-safe. */
send: (msg: M) => void
/** Read the driver's live state with the same signal handle used by mounted effects. */
state: Signal<S>
/** Group sends into one settled state notification. */
batch: (fn: () => void) => void
/** Aborted exactly once when this driver is disposed. */
signal: AbortSignal
}
TeaProgram
The view-less part of an LLui component: initial state, a pure reducer, and an optional effect interpreter. Pass this to {@link createTeaDriver} when a state machine needs the framework's real dispatch semantics without a DOM.
export interface TeaProgram<S, M, E = never> {
/** Produce the initial state and, optionally, effects to run after creation. */
init: () => S | [S, E[]]
/** Fold one message over the current state and optionally emit effects. */
update: (state: S, msg: M) => S | [S, E[]]
/** Interpret an emitted effect. A returned cleanup runs on driver disposal. */
onEffect?: (effect: E, api: TeaEffectApi<S, M>) => void | (() => void)
}
TeaTransition
One completed reducer call, observed after its state and effects have been accepted but before the settled state is committed or its effects run.
export interface TeaTransition<S, M, E> {
/** State supplied to the reducer. */
readonly previousState: S
/** Message supplied to the reducer. */
readonly msg: M
/** State returned by the reducer. */
readonly state: S
/** Effects returned by the reducer, in their original order. */
readonly effects: E[]
}
TelemetryComponentInfo
The component-identity subset telemetry reads (ComponentInfo is wider).
export interface TelemetryComponentInfo {
name: string
file: string | null
line: number | null
}
TelemetryEffectTimelineEntry
The timeline subset telemetry reads (EffectTimelineEntry is wider).
export interface TelemetryEffectTimelineEntry {
effectId: string
type?: string
phase: string
timestamp: number
}
TelemetryMessageRecord
The message-history subset telemetry reads (MessageRecord is wider).
export interface TelemetryMessageRecord {
index: number
timestamp: number
msg: unknown
}
TelemetryPendingEffect
The pending-effect subset telemetry reads (PendingEffect is wider).
export interface TelemetryPendingEffect {
id: string
type?: string
dispatchedAt?: number
payload?: unknown
}
TelemetrySource
What the collectors need from a registry entry. A structural subset of
LluiDebugAPI in which everything but getState is optional: the collectors
probe each method before calling it, so an older runtime — or a test stub —
is valid input, and a full LluiDebugAPI satisfies it by construction.
export interface TelemetrySource {
getState(): unknown
getMessageHistory?(opts?: { since?: number; limit?: number }): TelemetryMessageRecord[]
getPendingEffects?(): TelemetryPendingEffect[]
getEffectTimeline?(limit?: number): TelemetryEffectTimelineEntry[]
getComponentInfo?(): TelemetryComponentInfo
}
TransitionOptions
Enter/leave/cross transition hooks shared by the animation/transition
helpers (@llui/transitions) and the structural primitives that
accept them. Runtime-agnostic — operates on raw DOM Nodes.
export interface TransitionOptions {
enter?: (nodes: Node[]) => void | Promise<void>
leave?: (nodes: Node[]) => void | Promise<void>
onTransition?: (ctx: { entering: Node[]; leaving: Node[]; parent: Node }) => void | Promise<void>
}
UpdateExplanation
export interface UpdateExplanation {
bindingIndex: number
bindingMask: number
lastDirtyMask: number
matched: boolean
accessorResult: unknown
lastValue: unknown
changed: boolean
}
ValidationError
export interface ValidationError {
path: string
message: string
/** Set by the legacy validator; the signal validator omits these. */
expected?: string
received?: string
/**
* A complete, valid example message — attached to the FIRST error so an LLM
* can construct the corrected message in one shot instead of inferring the
* shape from the schema. Built for the targeted variant (or a representative
* variant when the variant is unknown/missing), reusing any valid fields the
* caller already supplied.
*/
example?: Record<string, unknown>
}
VirtualEachSpec
export interface VirtualEachSpec<T> extends EachSource<T> {
key: (item: T) => string | number
/** Row height in pixels. A `number` is a uniform fixed height (O(1) windowing);
* a function returns a per-item height, letting rows vary — the window, spacer,
* and row offsets are computed from cumulative heights (prefix sums, rebuilt when
* `items` changes). Heights must be known from the data; measured/auto heights
* are not supported. */
itemHeight: number | ((item: T, index: number) => number)
/** scroll-container height in pixels */
containerHeight: number
/** extra rows rendered above/below the viewport (default 3) */
overscan?: number
/** optional class on the scroll container */
class?: string
/** Additional COMPONENT-STATE dep paths the rows read, merged into the
* structural binding's deps so a state-only change (items unchanged) still fires
* the reconcile and refreshes visible rows. The authoring `virtualEach` passes
* `['']` (whole state); a compiled tier could pass precise paths. Without it, a
* row reading component state was frozen out of state-only changes (stale DOM). */
extraDeps?: readonly string[]
/** build a row; `getCtx` exposes the row's live `{ item, state, index }` ctx
* (same shape as `signalEach`) for runtime item/index handles. */
renderRow: (getCtx: () => RowCtx<T>) => Renderable
}
Classes
LluiFrameworkError
An error raised by the framework about the AUTHORING — a shape that cannot be mounted, a helper reached outside a build, a lowering that did not happen. Distinct from any throw originating in user accessor/commit code.
Never contained by the mount error boundary; see {@link isFrameworkError}.
class LluiFrameworkError extends Error {
__lluiFrameworkError
constructor(message: string)
}
Constants
a
const a
article
const article
aside
const aside
blockquote
const blockquote
br
const br
button
const button
canvas
const canvas
caption
const caption
circle
const circle
code
const code
COMPILER_META_KEYS
Emitted property key per metadata field: the record KEY is the descriptive name (the documentation vocabulary), the VALUE is the literal property key present in the bundle.
const COMPILER_META_KEYS
dd
const dd
details
const details
div
const div
dl
const dl
dt
const dt
ellipse
const ellipse
em
const em
fieldset
const fieldset
figcaption
const figcaption
figure
const figure
footer
const footer
form
const form
g
const g
h1
const h1
h2
const h2
h3
const h3
h4
const h4
h5
const h5
h6
const h6
HEAD_SINK
Context carrying the active sink. Default null → the client per-document
fallback; SSR / explicit coordination seed a sink here.
const HEAD_SINK: Context<HeadSink | null>
header
const header
hr
const hr
img
const img
input
const input
label
const label
legend
const legend
li
const li
line
const line
main
const main
nav
const nav
ol
const ol
optgroup
const optgroup
option
const option
p
const p
path
const path
polygon
const polygon
polyline
const polyline
pre
const pre
rect
const rect
section
const section
select
const select
small
const small
span
const span
strong
const strong
summary
const summary
svg
const svg
svgText
const svgText
table
const table
tbody
const tbody
td
const td
textarea
const textarea
th
const th
thead
const thead
time
const time
tr
const tr
ul
const ul
Public Entry Points
@llui/dom/devtools
Functions
installSignalDebug() from @llui/dom/devtools
Build the signal debug API and register it. Returns an unregister function.
function installSignalDebug(hooks: SignalDebugHooks): () => void
startRelay() from @llui/dom/devtools
Register the MCP relay for this page. Discovery: fetch the Vite
plugin's /__llui_mcp_status marker for the live port and connect; if
unreachable, fall back to the compile-time port. No retry loop —
window.__lluiConnect(port?) is exposed for manual/late connection,
and the vite-plugin's llui:mcp-ready HMR event also forwards here.
function startRelay(port = 5200): void
Interfaces
BindingDebugInfo from @llui/dom/devtools
export interface BindingDebugInfo {
index: number
mask: number
lastValue: unknown
kind: string
key: string | undefined
dead: boolean
perItem: boolean
}
BindingLocation from @llui/dom/devtools
export interface BindingLocation {
bindingIndex: number
kind: string
key: string | undefined
mask: number
lastValue: unknown
/** How the binding's node relates to the matched element. */
relation: 'self' | 'text-child' | 'comment-child'
}
ComponentInfo from @llui/dom/devtools
export interface ComponentInfo {
name: string
file: string | null
line: number | null
/** Identifies which runtime mounted the component. */
runtime?: 'signal' | 'legacy'
}
ElementReport from @llui/dom/devtools
export interface ElementReport {
selector: string
tagName: string
attributes: Record<string, string>
classes: string[]
dataset: Record<string, string>
text: string
computed: {
display: string
visibility: string
position: string
width: number
height: number
}
boundingBox: { x: number; y: number; width: number; height: number }
bindings: Array<{
bindingIndex: number
kind: string
mask: number
lastValue: unknown
relation: 'self' | 'text-child' | 'comment-child'
}>
}
HydrationDivergence from @llui/dom/devtools
export interface HydrationDivergence {
path: string
kind: 'attribute' | 'text' | 'structural'
server: unknown
client: unknown
}
LluiDebugAPI from @llui/dom/devtools
The relay-callable debug surface of a mounted LLui component.
Required methods are implemented by every runtime (and by
installSignalDebug). Optional methods are binding/scope/effect
introspection that only the legacy runtime provides — callers must
feature-detect and degrade when they are absent.
export interface LluiDebugAPI {
// ── Core (always implemented) ──────────────────────────────────
getState(): unknown
send(msg: unknown): void
flush(): void
getMessageHistory(opts?: { since?: number; limit?: number }): MessageRecord[]
evalUpdate(msg: unknown): { state: unknown; effects: unknown[] }
exportTrace(): {
lluiTrace: 1
component: string
generatedBy: string
timestamp: string
entries: Array<{ msg: unknown; expectedState: unknown; expectedEffects: unknown[] }>
}
clearLog(): void
validateMessage(msg: unknown): ValidationError[] | null
searchState(query: string): unknown
getMessageSchema(): MessageSchemaInfo | object | null
getStateSchema(): object | null
getEffectSchema(): object | null
getComponentInfo(): ComponentInfo
snapshotState(): unknown
restoreState(snap: unknown): void
// ── Binding / scope introspection (legacy-only; optional) ──────
getBindings?(): BindingDebugInfo[]
whyDidUpdate?(bindingIndex: number): UpdateExplanation
getMaskLegend?(): Record<string, number> | null
decodeMask?(mask: number): string[]
getBindingsFor?(selector: string): BindingLocation[]
getBindingGraph?(): Array<{ statePath: string; bindingIndices: number[] }>
getBindingSource?(bindingIndex: number): { file: string; line: number; column: number } | null
forceRerender?(): { changedBindings: number[] }
getEachDiff?(sinceIndex?: number): EachDiff[]
getScopeTree?(opts?: { depth?: number; scopeId?: string }): LifetimeNode
getDisposerLog?(limit?: number): DisposerEvent[]
// ── DOM inspection (legacy-only; optional) ─────────────────────
inspectElement?(selector: string): ElementReport | null
getRenderedHtml?(selector?: string, maxlength?: number): string
dispatchDomEvent?(
selector: string,
type: string,
init?: EventInit,
): {
dispatched: boolean
messagesProducedIndices: number[]
resultingState: unknown | null
}
getFocus?(): {
selector: string | null
tagName: string | null
selectionStart: number | null
selectionEnd: number | null
}
getHydrationReport?(): HydrationDivergence[]
// ── Effect introspection (legacy-only; optional) ───────────────
getPendingEffects?(): PendingEffect[]
getEffectTimeline?(limit?: number): EffectTimelineEntry[]
mockEffect?(
match: EffectMatch,
response: unknown,
opts?: { persist?: boolean },
): { mockId: string }
resolveEffect?(effectId: string, response: unknown): { resolved: boolean }
// ── Time-travel / coverage / eval (legacy-only; optional) ──────
stepBack?(n: number, mode: 'pure' | 'live'): { state: unknown; rewindDepth: number }
getCoverage?(): CoverageSnapshot
getCompiledSource?(viewFn?: string): { pre: string; post: string } | null
getMsgMaskMap?(): Record<string, number> | null
evalInPage?(code: string): {
result: unknown | { error: string }
sideEffects: {
stateChanged: StateDiff | null
newHistoryEntries: number
newPendingEffects: PendingEffect[]
dirtyBindingIndices: number[]
}
}
}
MessageRecord from @llui/dom/devtools
export interface MessageRecord {
index: number
timestamp: number
msg: unknown
stateBefore: unknown
stateAfter: unknown
effects: unknown[]
/** Present only on the legacy runtime, which computes a dirty mask per update. */
dirtyMask?: number
}
MessageSchemaInfo from @llui/dom/devtools
export interface MessageSchemaInfo {
discriminant: string
variants: Record<string, Record<string, unknown>>
}
SignalDebugHooks from @llui/dom/devtools
Everything the signal debug API needs from a mounted component. Supplied by mountSignalComponent; keeps this module decoupled from the mount internals.
export interface SignalDebugHooks {
name: string
getState: () => unknown
/** replace state and re-render (restore / time-travel) */
setState: (s: unknown) => void
send: (msg: unknown) => void
/** pure reducer, normalized to [state, effects] (for evalUpdate / dry-run) */
pureUpdate: (s: unknown, msg: unknown) => [unknown, unknown[]]
/** captured message log (newest last); installSignalDebug reads it live */
history: readonly SignalMessageRecord[]
clearHistory: () => void
msgSchema?: object
stateSchema?: object
effectSchema?: object
componentMeta?: { file: string; line: number }
}
SignalMessageRecord from @llui/dom/devtools
export interface SignalMessageRecord {
index: number
timestamp: number
msg: unknown
stateBefore: unknown
stateAfter: unknown
effects: unknown[]
}
StateDiff from @llui/dom/devtools
export interface StateDiff {
added: Record<string, unknown>
removed: Record<string, unknown>
changed: Record<string, { from: unknown; to: unknown }>
}
UpdateExplanation from @llui/dom/devtools
export interface UpdateExplanation {
bindingIndex: number
bindingMask: number
lastDirtyMask: number
matched: boolean
accessorResult: unknown
lastValue: unknown
changed: boolean
}
ValidationError from @llui/dom/devtools
export interface ValidationError {
path: string
message: string
/** Set by the legacy validator; the signal validator omits these. */
expected?: string
received?: string
/**
* A complete, valid example message — attached to the FIRST error so an LLM
* can construct the corrected message in one shot instead of inferring the
* shape from the schema. Built for the targeted variant (or a representative
* variant when the variant is unknown/missing), reusing any valid fields the
* caller already supplied.
*/
example?: Record<string, unknown>
}
@llui/dom/debug-collect
Functions
callRegistryMethod() from @llui/dom/debug-collect
Dispatch a registry pseudo-method, mirroring the relay call shape.
function callRegistryMethod(access: ComponentRegistryAccess, method: RegistryMethod, args: readonly unknown[]): ListComponentsResult | SelectComponentResult
collectComponentInfo() from @llui/dom/debug-collect
Collect identity information for every mounted host component. Returns null
when no debug API is present so callers keep their own fallback values.
function collectComponentInfo(opts: DebugCollectOptions = {}): ComponentInfoSnapshot | null
collectDebugSnapshot() from @llui/dom/debug-collect
Collect runtime telemetry from every mounted host component. Returns an empty object when no debug API is present (production builds, or dev with devtools-mode off). Never throws: a component method that blows up degrades that one field, because the caller is usually mid-screenshot.
function collectDebugSnapshot(opts: DebugCollectOptions = {}): DebugSnapshot
componentInfoExpression() from @llui/dom/debug-collect
As debugSnapshotExpression, for ComponentInfoSnapshot | null.
function componentInfoExpression(opts: SerializableCollectOptions = {}): string
debugCollectSource() from @llui/dom/debug-collect
The collector graph as standalone JavaScript source: a preamble declaring the module constants the functions read, then every function declaration. Dropping this into a fresh scope rebinds the free identifiers, so the page copy IS the in-process implementation rather than a mirror of it.
function debugCollectSource(): string
debugSnapshotExpression() from @llui/dom/debug-collect
A self-contained expression that evaluates to a DebugSnapshot when run in a
page (Playwright's page.evaluate(string) / CDP Runtime.evaluate). This is
how an out-of-process caller gets the collector WITHOUT hand-copying it.
function debugSnapshotExpression(opts: SerializableCollectOptions = {}): string
globalRegistryAccess() from @llui/dom/debug-collect
Registry access backed by the runtime globals.
function globalRegistryAccess(): ComponentRegistryAccess
hostComponentEntries() from @llui/dom/debug-collect
Registry entries belonging to the host app, in registration order.
function hostComponentEntries<T>(components: Record<string, T>): Array<[string, T]>
isRegistryMethod() from @llui/dom/debug-collect
function isRegistryMethod(method: string): method is RegistryMethod
listComponents() from @llui/dom/debug-collect
Every mounted component's key, plus the key of the selected one (matched by identity — the pointer is the API object, not a name).
function listComponents(access: ComponentRegistryAccess): ListComponentsResult
selectComponent() from @llui/dom/debug-collect
Move the selection to key. Throws when the key is not registered — the
relays turn that into the error frame their callers already expect.
function selectComponent(access: ComponentRegistryAccess, key: string): SelectComponentResult
Types
RegistryMethod from @llui/dom/debug-collect
Registry-level pseudo-methods. They are NOT members of LluiDebugAPI — they
operate on the registry rather than on one component — but they travel the
same relay channel, so every relay has to recognize them.
export type RegistryMethod = '__listComponents' | '__selectComponent'
SerializableCollectOptions from @llui/dom/debug-collect
The options that survive serialization into a page expression — components
is a live object graph and cannot cross the CDP boundary.
export type SerializableCollectOptions = Omit<DebugCollectOptions, 'components'>
Interfaces
ComponentInfoSnapshot from @llui/dom/debug-collect
export interface ComponentInfoSnapshot {
/** Names of all currently mounted host components (registry keys). */
componentPath: string[]
/** Metadata for the first mounted component — the most likely "owning"
* component when a capture doesn't carry a precise scope. */
componentMeta: DebugComponentMeta | null
}
ComponentRegistryAccess from @llui/dom/debug-collect
How a caller reaches the component registry and the "selected component"
pointer. In the page that pointer is globalThis.__lluiDebug
(globalRegistryAccess()); in the MCP server's direct mode it is the relay's
own attached API. Abstracting it is what lets both sides run ONE resolver.
export interface ComponentRegistryAccess {
/** The live registry, or undefined when nothing has mounted. */
registry(): Record<string, LluiDebugAPI> | undefined
/** The currently selected API, or undefined when nothing is selected. */
active(): LluiDebugAPI | undefined
/** Point the selection at `api`. */
setActive(api: LluiDebugAPI): void
}
DebugCollectOptions from @llui/dom/debug-collect
export interface DebugCollectOptions {
/** Override the registry lookup. Tests inject a stub map; the page path and
* the HUD both leave this unset and read `globalThis.__lluiComponents`. */
components?: Record<string, TelemetrySource>
/** Cap on `messageLog` entries; default 50. */
messageLimit?: number
/** Cap on `effects.recent` entries; default 50. */
effectLimit?: number
}
DebugComponentMeta from @llui/dom/debug-collect
export interface DebugComponentMeta {
file: string
line: number
name: string
}
DebugMessageLogEntry from @llui/dom/debug-collect
export interface DebugMessageLogEntry {
ts: string
component: string
msg: unknown
}
DebugPendingEffectEntry from @llui/dom/debug-collect
export interface DebugPendingEffectEntry {
id: string
component: string
effect: unknown
sinceMs: number
}
DebugRecentEffectEntry from @llui/dom/debug-collect
export interface DebugRecentEffectEntry {
ts: string
component: string
effect: { type: string | null; id: string }
outcome: 'ok' | 'error' | 'cancelled'
}
DebugSnapshot from @llui/dom/debug-collect
A point-in-time telemetry snapshot of every mounted component. Structurally a
NoteBody subset (@llui/notes-format) — the notebook is its main consumer —
but declared here so the runtime keeps its zero-dependency root position.
export interface DebugSnapshot {
stateSnapshot?: Record<string, unknown>
messageLog?: DebugMessageLogEntry[]
effects?: {
pending: DebugPendingEffectEntry[]
recent: DebugRecentEffectEntry[]
}
}
ListComponentsResult from @llui/dom/debug-collect
export interface ListComponentsResult {
components: string[]
active: string | null
}
SelectComponentResult from @llui/dom/debug-collect
export interface SelectComponentResult {
active: string
}
TelemetryComponentInfo from @llui/dom/debug-collect
The component-identity subset telemetry reads (ComponentInfo is wider).
export interface TelemetryComponentInfo {
name: string
file: string | null
line: number | null
}
TelemetryEffectTimelineEntry from @llui/dom/debug-collect
The timeline subset telemetry reads (EffectTimelineEntry is wider).
export interface TelemetryEffectTimelineEntry {
effectId: string
type?: string
phase: string
timestamp: number
}
TelemetryMessageRecord from @llui/dom/debug-collect
The message-history subset telemetry reads (MessageRecord is wider).
export interface TelemetryMessageRecord {
index: number
timestamp: number
msg: unknown
}
TelemetryPendingEffect from @llui/dom/debug-collect
The pending-effect subset telemetry reads (PendingEffect is wider).
export interface TelemetryPendingEffect {
id: string
type?: string
dispatchedAt?: number
payload?: unknown
}
TelemetrySource from @llui/dom/debug-collect
What the collectors need from a registry entry. A structural subset of
LluiDebugAPI in which everything but getState is optional: the collectors
probe each method before calling it, so an older runtime — or a test stub —
is valid input, and a full LluiDebugAPI satisfies it by construction.
export interface TelemetrySource {
getState(): unknown
getMessageHistory?(opts?: { since?: number; limit?: number }): TelemetryMessageRecord[]
getPendingEffects?(): TelemetryPendingEffect[]
getEffectTimeline?(limit?: number): TelemetryEffectTimelineEntry[]
getComponentInfo?(): TelemetryComponentInfo
}
Constants
DEVTOOLS_COMPONENT_PREFIX from @llui/dom/debug-collect
Component-name prefix reserved for dev-tooling UI authored with LLui — today
the @llui/devmode-annotate HUD (browse view, rect overlay, element picker,
the shell). Those components register into the same __lluiComponents
registry as the host app's, so every collector skips them: the tooling must be
invisible to the telemetry it captures.
const DEVTOOLS_COMPONENT_PREFIX
@llui/dom/ssr
Functions
browserEnv() from @llui/dom/ssr
Wrap the browser globals as a DomEnv. Used as the default env for
mountApp / hydrateSignalApp on the client.
The returned object delegates to globalThis.document / globalThis.X
lazily — evaluating browserEnv() on a server process before a DOM
exists is safe because the delegation only dereferences the globals
when a method is actually called.
Never mutates globalThis. A process with no browser globals that
invokes one of the factory methods gets a TypeError / ReferenceError
at the call site — which is correct: you're trying to build DOM on a
runtime that has no DOM.
function browserEnv(): DomEnv
Interfaces
DomEnv from @llui/dom/ssr
Minimal DOM surface that @llui/dom's internals depend on. Passed to
mountApp / hydrateSignalApp / renderToString as a context object so
the runtime never reaches for globalThis.document directly.
Why an injected shape instead of a global shim:
- Bundler-friendly. A Cloudflare Worker that imports
@llui/dom/ssr/linkedomreaches only linkedom via its module graph. Noawait import('jsdom')appears in reachable source, so rollup doesn't inline the 9 MiB jsdom bundle. - Concurrency-safe. Two
renderToStringcalls can pass different envs; no process-level singleton to collide on. - Strict-isolate safe. No
globalThis[key] = ...mutation — Cloudflare workerd and Deno strict modes forbid it.
The surface is deliberately narrow: exactly the methods and
constructors the runtime touches. Grep document\. /
instanceof (HTMLElement|Element|...) inside @llui/dom/src for
the exhaustive set.
export interface DomEnv {
// ── Factories ────────────────────────────────────────────────────
createElement(tag: string): Element
createElementNS(ns: string, tag: string): Element
createTextNode(text: string): Text
createComment(text: string): Comment
createDocumentFragment(): DocumentFragment
/**
* Used by `each()`'s fast clear/bulk-remove paths to delete a range
* of siblings in one call. SSR adapters that don't need those paths
* (jsdom + linkedom both do) can stub — the runtime tolerates a
* missing range during SSR render, which never hits the bulk paths.
*/
createRange(): Range
// ── Node / element constructors ─────────────────────────────────
// Exposed for `instanceof` checks in binding targeting + for any
// rare site that needs to construct a node type directly.
readonly Element: typeof Element
readonly Node: typeof Node
readonly Text: typeof Text
readonly Comment: typeof Comment
readonly DocumentFragment: typeof DocumentFragment
readonly HTMLElement: typeof HTMLElement
readonly HTMLTemplateElement: typeof HTMLTemplateElement
readonly ShadowRoot: typeof ShadowRoot
// ── Event constructor ───────────────────────────────────────────
readonly MouseEvent: typeof MouseEvent
/**
* Parse an HTML fragment string into a `DocumentFragment`. Used by
* `unsafeHtml()`. Browsers and jsdom parse via template-element
* innerHTML; linkedom has its own fragment parser. Adapter chooses
* the right mechanism.
*/
parseHtmlFragment(html: string): DocumentFragment
/**
* Resolve a CSS selector against the env's root document. Used by
* `portal()` to locate its target when `opts.target` is a string.
*
* Returns `null` when the selector doesn't match — portal callers
* treat a null target as a no-op (render nothing), so adapters on
* runtimes where no real document exists (detached linkedom, empty
* shadow root, etc.) can safely return `null` here.
*
* Required — making this mandatory on the interface means a custom
* env that forgets to wire up selector resolution fails compile
* instead of silently falling back to `globalThis.document` at
* render time (which would crash under Cloudflare Workers + other
* strict-isolate runtimes). The three first-party envs
* (`browserEnv`, `jsdomEnv`, `linkedomEnv`) all implement it.
*/
querySelector(selector: string): Element | null
/**
* @internal Lets hot-path code (e.g. `el-split.ts`'s template-clone)
* skip env indirection when the env wraps the browser globals. Only
* set by `browserEnv()`.
*/
readonly isBrowser?: boolean
}
@llui/dom/ssr/jsdom
Functions
jsdomEnv() from @llui/dom/ssr/jsdom
Construct a DomEnv backed by jsdom. The heavyweight jsdom window is created
once per process and shared; each call mints a FRESH document from it, so the
returned env has its own isolated node tree (no cross-request node sharing)
while reusing the window's constructors — a fraction of the per-call cost of a
full new JSDOM(...).
Requires jsdom as an installed dependency. If you don't want the
jsdom bundle (e.g. on Cloudflare Workers), use linkedomEnv() from
@llui/dom/ssr/linkedom instead.
function jsdomEnv(): Promise<DomEnv>
@llui/dom/ssr/linkedom
Functions
linkedomEnv() from @llui/dom/ssr/linkedom
Construct a DomEnv backed by a fresh linkedom instance. Each call
returns a new env — no process-level state, safe under concurrency
and compatible with Cloudflare Workers.
Requires linkedom as an installed dependency.
function linkedomEnv(): Promise<DomEnv>
@llui/dom/internal
Functions
__clientOnlyStub() from @llui/dom/internal
Server-side stub for a 'use client' component. Compiler-ssr emits
export const Foo = __clientOnlyStub("Foo") for each named export of
a client-only module so SSR never imports the client's browser-only
dependencies. The stub renders nothing on the server; the real module
loads and hydrates on the client.
function __clientOnlyStub(name: string): SignalComponentDef<object, never, never>
@llui/dom/escape-hatch
Functions
subApp() from @llui/dom/escape-hatch
Mount an isolated sub-application at this point in the view. Returns the anchor
node(s) to splice into the surrounding view array (...subApp({ … })). Drive
the instance — push props in, bubble messages out — via onHandle's handle;
the sub-app shares no state with the host. Disposed automatically when the host
unmounts.
function subApp<S, M, E = never>(spec: SubAppSpec<S, M, E>): Renderable
Interfaces
SubAppSpec from @llui/dom/escape-hatch
Spec for {@link signalSubApp} — an isolated child component boundary.
export interface SubAppSpec<S, M, E = never> {
/** Why a separate update loop / mask scope is warranted (third-party UI, a
* long-lived loop with no reactive props, a 60fps layer). Documents intent at
* the call site; not consulted at runtime. */
reason: string
/** The component to mount in isolation. */
def: SignalComponentDef<S, M, E>
/** Seed state, overriding `def.init()`'s state (init still runs for effects).
* The bridge for "props in": the host pushes fresh data via the handle's `send`. */
initialState?: S
/** Context values to replay into the isolated build (provide/useContext). */
contexts?: ReadonlyMap<symbol, unknown>
/** Receive the mounted handle (send/subscribe/dispose) — the channel for pushing
* props in and bubbling messages out, since the sub-app shares no state with the host. */
onHandle?: (handle: SignalComponentHandle<S, M>) => void
}