npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@microsoft/webui-framework

v0.0.15

Published

WebUI Framework Next — Preact-inspired lightweight Web Component runtime with SSR hydration. 15KB minified, compiled-template path mapping, no hydration markers.

Downloads

631

Readme

@microsoft/webui-framework

Lightweight Web Component runtime for WebUI apps.

This package is the browser-side runtime used by webui build --plugin=webui. It provides:

  • WebUIElement for SSR hydration and client-created elements
  • @observable, @attr, and @volatile decorators
  • compiled template path mapping for direct DOM binding resolution
  • light DOM or shadow DOM rendering (--dom=light|shadow flag)
  • SSR state seeding from window.__webui.state (like Preact's props)

If you are building WebUI apps in this repo, this is the component model used by examples like examples/app/todo-webui, examples/app/commerce, and examples/app/contact-book-manager.

📖 Full documentation at microsoft.github.io/webui, see the Interactivity Guide for component authoring patterns. For framework internals (hydration, path resolution, reactive update model), see RENDERING.md.

Install

In this workspace:

{
  "dependencies": {
    "@microsoft/webui-framework": "workspace:*"
  }
}

Outside the workspace:

pnpm add @microsoft/webui-framework

TypeScript must use legacy decorators:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}

Quick Example

  1. Author a component class in TypeScript
  2. Author a WebUI template in HTML
  3. Run webui build --plugin=webui
  4. The runtime hydrates SSR output or creates client-side components using compiled path mapping

counter-card.ts

import { WebUIElement, attr, observable, volatile } from '@microsoft/webui-framework';

export class CounterCard extends WebUIElement {
  @attr label = 'Clicks';
  @observable count = 0;

  @volatile
  get doubled(): number {
    return this.count * 2;
  }

  increment(): void {
    this.count += 1;
  }
}

CounterCard.define('counter-card');

counter-card.html

<p>{{label}}: {{count}} ({{doubled}})</p>
<button @click="{increment()}">Increment</button>

Build with --dom=shadow (default) to wrap in a declarative shadow root, or --dom=light for light DOM rendering.

Use it from your page

<counter-card label="Taps"></counter-card>

Build with the WebUI plugin

cargo run -p microsoft-webui-cli -- build ./src --out ./dist --plugin=webui

The compiler/plugin generates the template metadata consumed by the runtime. In normal app code, you should not need to hand-author window.__webui.templates.

Property binding lifecycle

Property bindings use the : prefix to pass values directly to child DOM properties:

<profile-card :config="{{settings}}"></profile-card>

For client-created component trees, the runtime upgrades the cloned child elements while they are still detached, wires bindings, and applies the first binding pass before appending them to the connected DOM. A child can read an initial parent-provided property in connectedCallback. If the parent value is not set, the child may initialize its own fallback there, and later parent updates still flow through the live binding.

DOM strategy (--dom)

The --dom flag controls how the server renders component content:

| Flag | Behavior | |------|----------| | --dom=shadow (default) | Wraps component HTML in <template shadowrootmode="open"> | | --dom=light | Renders component content as direct children of the host element |

The runtime auto-detects which mode was used at hydration time:

  • If a shadowRoot already exists → shadow DOM SSR path
  • If childNodes exist but no shadow root → light DOM SSR path
  • If neither → client-created path (uses meta.sd to decide)

Light DOM is useful for simpler styling (CSS inheritance works naturally) and better search-engine indexing. Shadow DOM provides style encapsulation.


API Reference

WebUIElement

Base class for framework components.

| Member | Purpose | |--------|---------| | static define(tagName) | Register the class as a custom element | | $emit(name, detail?) | Dispatch a bubbling, composed CustomEvent | | $update() | Force a reactive update (normally called automatically) | | setState(state) | Populate @observable properties from router/server state | | disconnectedCallback() | Override for cleanup (global listeners, etc.) |

In most components you do not call $update() directly. Property changes through @observable and @attr trigger updates for you.

@observable

Marks a property as reactive. When the value changes, the framework re-evaluates the compiled bindings that reference it.

class SearchPanel extends WebUIElement {
  @observable open = false;

  toggle(): void {
    this.open = !this.open;
  }
}

@attr

Like @observable but also reflects to/from an HTML attribute (kebab-case).

class ProductPrice extends WebUIElement {
  @attr currency = 'USD';
  @attr({ attribute: 'amount-cents' }) amountCents = '0';
}

Notes:

  • default attribute names use kebab-case
  • attribute values arrive as strings
  • use @observable for richer client-only state

@volatile

Marks a computed getter that should be re-read whenever bindings access it.

class CartSummary extends WebUIElement {
  @observable items: Array<{ count: number }> = [];

  @volatile
  get totalCount(): number {
    return this.items.reduce((sum, item) => sum + item.count, 0);
  }
}

Template Features

The WebUI plugin compiles these template features into runtime metadata:

  • text bindings: {{title}}
  • attribute bindings: href="{{item.href}}"
  • event handlers: @click="{onClick()}", @click="{onSelect(item.id, e)}"
  • refs: w-ref="addInput"
  • conditionals: <if condition="...">
  • repeats: <for each="item in items">

Example from examples/app/todo-webui:

<h1>{{title}}</h1>

<input
  class="add-input"
  w-ref="addInput"
  @keydown="{onAddKeydown(e)}"
/>

<for each="item in items">
  <todo-item
    id="{{item.id}}"
    title="{{item.title}}"
    state="{{item.state}}"
  ></todo-item>
</for>

Root-level events (e.g. @toggle-item="{onToggleItem(e)}") can be declared on the component's host element and are wired via meta.re.

Recommended Patterns

  • Treat decorated properties as the source of truth.
  • Update state with property assignments such as this.open = !this.open.
  • Use $emit() for child-to-parent communication.
  • Use w-ref for true DOM-only concerns like focus or reading input values.
  • Prefer @observable someValue!: T; when a value is expected to be seeded externally after construction.

Avoid imperative DOM mutation for application state that can be represented by reactive properties.


Performance Philosophy

This framework is designed for minimal memory, minimal work, zero waste. Every design decision optimizes for real-world interactive performance on resource-constrained devices.

Design principles

  1. No work on the hot path that doesn't change the DOM. $update(path) only visits bindings that reference the changed property. Everything else is skipped via a per-path index built once at hydration time.

  2. Zero allocations during updates. Targeted updates are a single Map.get() → direct array iteration. No intermediate arrays, no object creation, no spread operators on the update path.

  3. Parse once, clone forever. Compiled template HTML is parsed via innerHTML once per component tag and cached as a DocumentFragment. Every subsequent instance uses cloneNode(true) — DOM cloning is significantly faster than HTML parsing.

  4. Resolve event targets once. Event bindings store their target path in compiled metadata. Hydration resolves each target once, installs the listener directly, and captures the active repeat scope so handler arguments like item.id are read at dispatch.

  5. Single-pass hydration via path mapping. SSR DOM is matched to compiled template bindings through template-parallel traversal ($resolveSSR). No marker comments, no data attributes — just path-based node resolution. The hydration walk touches each DOM node exactly once.

  6. Keep the framework out of the GC's way. Fewer JS objects = fewer GC pauses. Binding arrays are pre-built at hydration time and reused across updates. No per-update temporaries.

Benchmark fixtures

The tests/fixtures/bench/ directory contains Playwright-driven benchmarks that validate these properties:

  • Update throughput: 50k single-prop mutations with 65 bindings
  • Repeat instantiation: 200 items created from compiled templates
  • Event memory: 1000 event bindings measured via heap snapshots

Run benchmarks with:

cd packages/webui-framework
npx playwright test tests/fixtures/bench/

What NOT to do

When contributing to the runtime, avoid these patterns:

  • Don't allocate on the update path. No [...spread], no new Map(), no object literals inside $updateBindings or $updateInstance.
  • Don't add querySelector calls during updates. All DOM references are pre-resolved at hydration time via compiled path mapping.
  • Don't use recursion in hot paths. Condition evaluation and DOM walks use iterative stacks.
  • Don't allocate on the update path for events. Event listeners are created once during hydration and should not trigger extra DOM lookup work later.
  • Don't re-parse template HTML. Always clone from the cached fragment.

Architecture

How It Fits Together

┌──────────────────────┐     ┌───────────────────────┐      ┌──────────────────────┐
│   Rust Compiler      │     │   Any Server          │      │   Browser            │
│                      │     │   (Rust/Go/C#/…)      │      │                      │
│  HTML template       │     │                       │      │  SSR HTML (light or  │
│  + expressions       │────▶│  TemplateMeta (JSON)  │────▶│  shadow DOM) +       │
│  + @if / @for        │     │  + state data         │      │  __webui.state JSON  │
│                      │     │                       │      │                      │
│  Outputs:            │     │  Renders:             │      │  Hydrates:           │
│  • TemplateMeta      │     │  • Full HTML page     │      │  • Path-based DOM    │
│  • Static HTML       │     │  • Shadow or light    │      │    resolution        │
│  • Binding metadata  │     │  • State as JSON      │      │  • O(1) updates      │
└──────────────────────┘     └───────────────────────┘      └──────────────────────┘

Key differentiator: language-agnostic SSR. React, Solid, Svelte, and Angular all require a JavaScript runtime on the server. This framework's SSR is driven by data (template metadata + state values), not code. Any language that can read the compiled metadata and produce HTML can serve as the SSR backend. No comment markers or data attributes are needed — the runtime resolves SSR DOM nodes via template-parallel path traversal.

Build → Serve → Hydrate → Update

flowchart LR
    subgraph Build ["Build Time (Rust)"]
        T[HTML Template] --> P[Parser Plugin]
        P --> M[TemplateMeta JSON]
        P --> H[Static HTML]
    end

    subgraph Serve ["Server (Any Language)"]
        M --> R[Route Handler]
        S[State Data] --> R
        R --> HTML["Full SSR HTML<br/>(shadow or light DOM)<br/>+ TemplateMeta &lt;script&gt;<br/>+ __webui.state &lt;script&gt;"]
    end

    subgraph Browser ["Browser"]
        HTML --> CE[Custom Element Upgrade]
        CE --> MT{$mount}
        MT -- SSR DOM exists --> SSR["$applySSRState<br/>$hydrate (path-based)"]
        MT -- No SSR DOM --> CL["$wire (from template)"]
        SSR --> BIND[Binding Arrays]
        CL --> BIND
        BIND --> UPD["$update() — O(1) patches"]
    end

Module Structure

graph TD
    EL["element.ts (~850 lines)<br/><i>Orchestrator</i><br/>$mount, $wire, $hydrate,<br/>$resolveSSR, $applySSRState,<br/>$update, events, cleanup"]

    DIFF["element/diff.ts (~130 lines)<br/><i>List Reconciliation</i><br/>keyed/sequential diffing<br/>for @for repeat blocks"]

    COND["element/conditions.ts<br/><i>Condition Evaluation</i><br/>evaluateCondition (iterative),<br/>conditionUsesPath"]

    TYPES["element/types.ts<br/><i>Shared Types</i><br/>TemplateInstance, TextBinding,<br/>AttrBinding, CondBinding,<br/>RepeatBinding, ScopeFrame,<br/>RepeatHost"]

    TMPL["template.ts<br/><i>Metadata Types + Registry</i><br/>TemplateMeta, getTemplate"]

    DEC["decorators.ts<br/><i>Reactive Properties</i><br/>@observable, @attr, @volatile"]

    LIFE["lifecycle.ts<br/><i>Hydration Timing</i><br/>Performance marks,<br/>hydration-complete event"]

    EL --> DIFF
    EL --> COND
    EL --> TMPL
    EL --> DEC
    EL --> LIFE
    DIFF --> TYPES
    EL --> TYPES

Lifecycle Detail

SSR Hydration Path

When the server renders a component, it emits HTML content (as a declarative shadow root or as light DOM children) along with a window.__webui.state JSON payload. The browser parses this DOM before any JavaScript runs. When the component's JS loads and connectedCallback fires, the framework uses compiled template paths to resolve SSR DOM nodes without any marker comments or data attributes:

sequenceDiagram
    participant Server
    participant Browser
    participant CE as Custom Element
    participant FW as Framework

    Server->>Browser: HTML (shadow or light DOM)<br/>+ __webui.state JSON
    Browser->>Browser: Parse HTML → DOM exists
    Browser->>CE: Custom element upgrade
    CE->>CE: attributeChangedCallback (pre-existing attrs)
    CE->>FW: connectedCallback() → $mount()
    FW->>FW: SSR DOM detected (shadow root or children exist)
    FW->>FW: $applySSRState() — seed observables from __webui.state
    FW->>FW: $hydrate() — template-parallel path resolution
    FW->>FW: $resolveSSR() — match SSR nodes via ordinal traversal
    FW->>FW: $wireEvents() + $wireRefs()
    FW->>FW: $buildPathIndex(), $ready = true
    Note over FW: DOM is already correct from SSR.<br/>No $update() call needed.

Client-Created Path

When a component is created dynamically (e.g. inside a @for loop or via document.createElement), there's no SSR DOM:

sequenceDiagram
    participant App
    participant CE as Custom Element
    participant FW as Framework

    App->>CE: document.createElement('my-comp')
    App->>CE: Append to DOM
    CE->>FW: connectedCallback() → $mount()
    FW->>FW: No SSR DOM → client path
    FW->>FW: Parse + clone template from meta.h
    FW->>FW: Attach to shadow root or light DOM
    FW->>FW: $wire(root, meta) — resolve via childNode paths
    FW->>FW: $wireEvents() + $wireRefs()
    FW->>FW: $buildPathIndex(), $ready = true
    FW->>FW: $update() — flush initial property values

Compiled Template Metadata

The Rust compiler transforms HTML templates into a TemplateMeta JSON object that describes every dynamic binding without any template syntax. This object is delivered to the browser as a <script> tag.

Metadata Shape

interface TemplateMeta {
  h: string;                           // Static HTML (no markers)
  tx?: [slot, parts][];                // Text run locators
  a?: CompiledAttrMeta[];              // Attribute bindings
  ag?: [path, start, count][];         // Attribute target groups
  c?: [conditionAST, blockIndex][];    // Conditional blocks
  cl?: SlotPath[];                     // Conditional anchor slots
  r?: [collection, itemVar, blockIdx][];// Repeat blocks
  rl?: SlotPath[];                     // Repeat anchor slots
  e?: [event, handler, argSpecs, targetPath][]; // Events
  b?: TemplateBlockMeta[];             // Nested block metadata
  sa?: string;                         // Adopted stylesheet specifier
  sd?: boolean;                        // Shadow DOM flag for client-created
  re?: [event, handler, argSpecs][];    // Root-level events
}

Example

Template:

<h1>{{title}}</h1>
<button @click="{increment()}">Count: {{count}}</button>

Compiled metadata:

{
  h: '<h1></h1><button>Count: </button>',
  tx: [
    [[[0], 0], [["title"]]],           // slot in <h1>, dynamic "title"
    [[[1], 1], ["Count: ", ["count"]]]  // slot in <button>, static + dynamic
  ],
  e: [["click", "increment", [], [1]]] // click -> increment, no event args
}

Condition AST

Conditions are emitted as compact tuples:

| Tuple | Meaning | Example | |-------|---------|---------| | [0, path] | Identifier (truthy check) | @if(visible) | | [1, left, op, right] | Comparison predicate | @if(count > 0) | | [2, inner] | Logical NOT | @if(!visible) | | [3, left, op, right] | Compound AND/OR | @if(a && b) |

The runtime evaluates these iteratively (stack-based, no recursion) to avoid call-stack depth in hot update paths.


Reactive Update Model

How @observable Triggers Updates

sequenceDiagram
    participant App as Application Code
    participant Dec as @observable setter
    participant FW as $update('count')
    participant IDX as Path Index
    participant DOM

    App->>Dec: this.count = 5
    Dec->>Dec: Store in _count backing field
    Dec->>Dec: Call countChanged(old, new) if defined
    Dec->>FW: $update('count') (if element.isConnected)
    FW->>IDX: Look up 'count' bindings + '*' wildcards
    IDX-->>FW: 2 text bindings + 1 volatile binding
    FW->>DOM: Patch only affected nodes

Why Updates Are O(affected)

After hydration, every dynamic value in the template is connected to a direct DOM node reference stored in a binding array. A per-path index maps each @observable property name to the subset of bindings that reference it.

When this.count = 5 fires, the @observable setter calls $update('count'), which looks up 'count' in the index and only patches the bindings that actually depend on count — not every binding in the component.

Computed/volatile getters (paths not in the @observable set) are stored under a wildcard key and always included in targeted updates.

// Targeted update (simplified):
const entry = this.$pathIndex.get(path);  // O(1) map lookup
const wild = this.$pathIndex.get('*');     // volatile/computed bindings
// Only walk affected bindings, not all 65+
for (const binding of [...entry.texts, ...wild.texts]) {
  if (binding.node.textContent !== str) {
    binding.node.textContent = str;  // Direct Text node reference
  }
}

No virtual DOM diffing. No selector queries. No tree walking. Each binding is a pre-resolved pointer to the exact DOM node that needs updating, and the path index ensures only affected pointers are visited.


SSR State Seeding

When the server renders <span>42</span> for @observable count = 0, the browser sees 42 in the DOM but the JavaScript property this.count is still 0 (the class default). Without seeding, the first $update() would overwrite the SSR content with the wrong value.

State seeding uses window.__webui.state — a JSON object emitted by the server handler as a <script> tag. Like Preact's props, this delivers the same data used for SSR rendering to the client. During $mount(), $applySSRState() writes matching keys directly to observable backing fields before any bindings are wired:

flowchart LR
    SCRIPT["&lt;script&gt;<br/>window.__webui.state = {<br/>  count: 42,<br/>  title: 'Hello'<br/>}"] --> APPLY["$applySSRState()"]
    APPLY --> SEED["Write to backing fields:<br/>this._count = 42<br/>this._title = 'Hello'"]
    SEED --> HYDRATE["$hydrate() — bindings match<br/>server-rendered DOM"]

$applySSRState() only sets properties that exist in the component's @observable set — unknown keys are ignored. Writes go to the backing field (_prop) directly, avoiding reactive updates before bindings are wired.


Repeat Reconciliation

@for(item of items) blocks support two reconciliation strategies, implemented in element/diff.ts (~130 lines):

Keyed Reconciliation

When the repeat block's root element has attribute bindings (e.g. <todo-item id="{{item.id}}">), the framework uses the first attribute as a key. This preserves DOM nodes across reorders:

flowchart TD
    subgraph Before ["Before: items = [A, B, C]"]
        A1["&lt;todo-item&gt; key=A"]
        B1["&lt;todo-item&gt; key=B"]
        C1["&lt;todo-item&gt; key=C"]
    end

    subgraph After ["After: items = [C, A]"]
        C2["&lt;todo-item&gt; key=C ← reused"]
        A2["&lt;todo-item&gt; key=A ← reused"]
        B2["key=B ← removed"]
    end

    A1 -.->|"moved"| A2
    C1 -.->|"moved"| C2
    B1 -.->|"destroyed"| B2

Sequential Reconciliation

When no keying attributes exist, items are matched by position. Excess items are removed; new items are appended.

SSR State Reading

On initial hydration, the repeat system walks existing SSR children and reconstructs collection instances by matching them against the compiled template via $resolveSSR path traversal. State is already seeded from window.__webui.state, so repeat items reflect the server-rendered list without parsing marker comments.


CSS Strategies

The framework supports three CSS delivery strategies:

| Strategy | How it works | |----------|-------------| | Link | <link> tag baked into meta.h — loaded by the browser naturally | | Inline | <style> tag baked into meta.h — no external request | | Module | <script type="importmap">{"imports":{"tag-name":"data:text/css,..."}}</script> in the HTML payload registers the CSS as a module under tag-name. The framework imports it via import(tag, { with: { type: 'css' } }) and applies the resulting CSSStyleSheet via adoptedStyleSheets for shadow DOM isolation |

CSS module stylesheets are cached so each component instance adopts the same parsed sheet without re-parsing CSS. The meta.sa field specifies the stylesheet specifier for a component.


Path-Based Binding Resolution

Unlike frameworks that use comment markers or data attributes to locate dynamic content, this framework uses compiled template paths — arrays of child-node indices that describe exactly where each binding lives in the DOM tree.

Client-created resolution ($resolve)

For client-created components, the DOM matches meta.h exactly (it was cloned from the parsed template fragment). Resolution is a simple child-node index walk:

// path = [1, 0] → root.childNodes[1].childNodes[0]
let cur: Node = root;
for (const idx of path) {
  cur = cur.childNodes[idx];
}

SSR resolution ($resolveSSR)

SSR DOM may differ from the compiled template — the browser's HTML parser can strip whitespace-only text nodes. $resolveSSR walks the SSR DOM and the compiled template DOM in parallel, translating each child-node index into an element-ordinal or text-ordinal lookup:

// For element nodes: count element siblings up to idx in template,
// then find the element at that ordinal in SSR DOM.
// For text nodes: same approach with text node ordinals.

This template-parallel traversal eliminates the need for any marker comments, data-* attributes, or DOM annotations. The SSR server emits clean HTML.


Performance Characteristics

| Operation | Cost | Why | |-----------|------|-----| | Initial hydration | O(bindings) | Single pass over compiled path mappings | | Reactive update | O(affected) | Per-path index skips unrelated bindings | | Conditional toggle | O(block size) | Create/destroy a block instance | | Repeat reconciliation | O(items) | Keyed map lookup or sequential scan | | Event wiring | O(events) | One-time during hydration |

What the framework does NOT do

  • No virtual DOM — no tree copy, no diff algorithm
  • No runtime template parsing — the Rust compiler handles all syntax
  • No innerHTML on updates — only textContent and setAttribute
  • No querySelector on updates — all nodes are pre-resolved references
  • No recursion in hot paths — conditions use iterative stack evaluation

Debugging Hydration

The runtime exposes hydration timing via the Performance API:

  • Per component: webui:hydrate:<tag>:start / webui:hydrate:<tag>:end
  • Global: webui:hydrate:total:start / webui:hydrate:total:end
  • Window event: webui:hydration-complete
window.addEventListener('webui:hydration-complete', () => {
  console.log('All initial framework components are hydrated.');
});

Where to Look Next

  • examples/app/todo-webui
  • examples/app/contact-book-manager
  • examples/app/commerce

Package Development

pnpm --dir packages/webui-framework build
pnpm --dir packages/webui-framework typecheck
pnpm --dir packages/webui-framework test