Skip to content

Latest commit

 

History

History
2109 lines (1734 loc) · 60.3 KB

File metadata and controls

2109 lines (1734 loc) · 60.3 KB

🎯 Functional Component API

Table of Contents

The Custom Elements Runtime provides a powerful, intuitive functional component API that emphasizes simplicity, type safety, and developer ergonomics. This API automatically handles reactive props, type inference, and event emission without requiring complex configuration objects.

✨ Key Features

  • 🔧 Zero Configuration - No complex setup required
  • ⚡ Automatic Reactivity - All props are automatically reactive
  • 🎯 Type Safety - Full TypeScript inference from function signatures
  • 📦 Props - Props are provided via useProps() with defaults and type inference. Avoid destructuring into local variables if you need reactivity — read from the props object or use computed/watch for derived reactive values.
  • 🚀 Strongly Typed Hooks - React-style hooks with perfect TypeScript inference
  • 📝 Runtime logging toggle - The runtime exports a setDevMode(boolean) helper so consumers can enable or disable development logs at runtime. Note: setting a global runtime flag in server-side environments is process-wide (per-process) and not per-request.
  • 🔄 Automatic Prop Parsing - Runtime extracts prop defaults from your useProps() calls (via a short discovery render in the browser) and uses them to infer prop types and observed attributes
  • 💡 Intuitive API - Familiar patterns similar to modern React/Vue components

🏗️ Basic Component Structure

The functional API follows a simple, intuitive pattern using context-based hooks:

import {
  component,
  html,
  css,
  useProps,
  useEmit,
  useOnConnected,
  useOnDisconnected,
  useOnAttributeChanged,
  useOnError,
  useStyle,
  ref,
  computed,
  watch,
} from '@jasonshimmy/custom-elements-runtime';

import {
  when,
  each,
  match,
} from '@jasonshimmy/custom-elements-runtime/directives';
import { eventBus } from '@jasonshimmy/custom-elements-runtime/event-bus';

component('component-name', () => {
  // Access reactive props via useProps hook
  const props = useProps({ prop1: 'default', prop2: 0 });

  // Get hooks with perfect TypeScript inference
  const emit = useEmit();

  // Set up lifecycle hooks
  useOnConnected(() => console.log('Component connected!'));
  useOnDisconnected(() => console.log('Component disconnected!'));
  useOnAttributeChanged((name, oldValue, newValue) => {
    console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
  });
  useOnError((error) => console.error('Component error:', error));

  // Component logic and rendering
  return html`<div>Your template</div>`;
});

Function Signature

component(
  tag: string,
  renderFn: () => VNode | VNode[] | Promise<VNode | VNode[]>,
)

Available Hooks

All hooks must be called during component render and provide perfect TypeScript inference:

  • useProps(defaults): Get reactive props with default values and type inference
  • defineModel(propName?, defaultValue?): Declare a two-way model binding (see defineModel)
  • useEmit(): Get the emit function for dispatching custom events
  • useOnConnected(callback): Set up lifecycle hook for when component connects to DOM
  • useOnDisconnected(callback): Set up lifecycle hook for when component disconnects from DOM
  • useOnAttributeChanged(callback): Set up lifecycle hook for when attributes change
  • useOnError(callback): Set up lifecycle hook for error handling
  • useStyle(fn: () => string): void: Provide a reactive CSS string applied as the component's scoped stylesheet. The fn callback runs during render; any reactive reads inside are tracked so styles update when dependencies change. See jit-css.md for usage with the css template helper.
  • useExpose(api): Publish methods and properties onto the host element as an imperative public API. See useExpose().
  • useSlots(): Inspect which named slots have been filled by the consumer. See useSlots().

� Two-Way Model Binding with defineModel

defineModel is a single hook that replaces the useProps + useEmit boilerplate needed for two-way binding. It wraps the common pattern of accepting a prop and emitting its counterpart update event, following the same contract as Vue's defineModel().

Default model (modelValue)

import {
  component,
  html,
  defineModel,
} from '@jasonshimmy/custom-elements-runtime';

component('my-input', () => {
  // parent uses :model="ref"
  const model = defineModel('');

  return html`
    <input :model="${model}" />
    <span>${model.value}</span>
  `;
});

The parent binds with :model as usual:

<my-input :model="${searchText}"></my-input>

Named model

component('my-field', () => {
  // parent uses :model:title="ref"
  const title = defineModel('title', '');
  const count = defineModel('count', 0);

  return html`
    <input :model="${title}" />
    <input type="number" :model="${count}" />
  `;
});

The parent binds with :model:propName:

<my-field :model:title="${heading}" :model:count="${qty}"></my-field>

How it works

Direction Mechanism
Parent → Child Parent passes a ref via :model; the child reads it through model.value
Child → Parent Setting model.value = x dispatches update:modelValue (or update:<propName>) from the host element; the parent's :model listener picks it up

The returned ModelRef is also recognised by the vdom :model directive, so you can pass it directly to native inputs inside the child template without any extra wiring.

Signature

// Default model — prop name is 'modelValue'
defineModel<T>(): ModelRef<T | undefined>
defineModel<T>(defaultValue: T): ModelRef<T>

// Named model
defineModel<T>(propName: string, defaultValue: T): ModelRef<T>

ModelRef<T> is a plain object with a writable value accessor:

interface ModelRef<T> {
  value: T; // get → reads current prop; set → emits update event
}

�� Props and Type Safety

Props with useProps Hook

Define your component props using the useProps() hook with default values. The runtime automatically creates reactive props with type inference:

component('user-card', () => {
  const props = useProps({
    name: 'Anonymous',
    age: 0,
    email: '',
    isActive: true,
    tags: [] as string[],
  });

  return html`
    <div class="user-card">
      <h3>${props.name}</h3>
      <p>Age: ${props.age}</p>
      <p>Email: ${props.email}</p>
      <p>Status: ${props.isActive ? 'Active' : 'Inactive'}</p>
      <ul>
        ${props.tags.map((tag) => html`<li>${tag}</li>`)}
      </ul>
    </div>
  `;
});

Implementation note: The runtime performs a lightweight "discovery render" in browser environments to detect useProps() default values and populate the component config (including observedAttributes). This discovery step is skipped during SSR (no window), so defaults may be discovered on the first real render server-side. Always call useProps() during the component render (not at module top-level) so the runtime can pick up defaults correctly.

Usage in HTML

<!-- Primitive values (strings, numbers, booleans) as attributes -->
<user-card
  name="John Doe"
  age="30"
  email="john@example.com"
  is-active="true"
></user-card>

<!-- Complex types (arrays/objects) should be passed as JS properties or bound via :bind
     Attributes containing JSON strings are NOT automatically parsed into arrays/objects. -->
<script>
  // Recommended: set as a JavaScript property after creating the element
  const el = document.createElement('user-card');
  el.name = 'John Doe';
  el.tags = ['developer', 'typescript']; // pass an actual array
  document.body.appendChild(el);
</script>

Complex types and attributes

The runtime only performs automatic attribute-to-prop conversion for primitive types (String, Number, Boolean). If you declare a prop with a complex default (for example tags: [] as string[]), attribute values remain raw strings — they are not automatically parsed as JSON into arrays or objects.

Recommended patterns:

  • Pass complex values as actual JavaScript properties on the element (preferred).
  • Use the runtime's :bind/property-binding mechanisms from a parent component so the child receives real JS values.
  • If you must accept a JSON string via an attribute, explicitly parse it inside your component and handle errors gracefully.

Example: safe parsing fallback inside a component

component('user-card', () => {
  const props = useProps({ name: 'Anonymous', tags: [] as string[] });

  // Ensure tags is an array whether it came from a property or an attribute string
  function parseTags(raw: unknown): string[] {
    if (Array.isArray(raw)) return raw as string[];
    if (typeof raw === 'string' && raw.trim()) {
      try {
        const parsed = JSON.parse(raw);
        return Array.isArray(parsed) ? parsed : [];
      } catch {
        return [];
      }
    }
    return [];
  }

  const tags = parseTags(props.tags as unknown);

  return html`
    <div>
      <h3>${props.name}</h3>
      <ul>
        ${tags.map((t) => html`<li>${t}</li>`)}
      </ul>
    </div>
  `;
});

Automatic Type Inference

The runtime automatically:

  • ✅ Extracts default values from useProps defaults object
  • ✅ Infers prop types from default values and TypeScript annotations
  • ✅ Creates reactive Proxy for all props with automatic dependency tracking
  • ✅ Converts attribute names (kebab-case) to prop names (camelCase)
  • ✅ Handles type conversion (String, Number, Boolean)

Note about destructuring props

  • ⚠️ If you destructure props (for example const { foo } = useProps({ foo: 0 })) you receive a copy of the current value. That local variable will not stay reactive — updates to the prop will not update the previously-destructured variable. To keep reactivity, access props via the returned props object (for example props.foo) or use computed/watch to derive reactive values.

🚀 Event Emission

Use the useEmit() hook to get a strongly typed emit function:

component('interactive-button', () => {
  const props = useProps({ label: 'Click me', disabled: false });
  const emit = useEmit();

  const handleClick = () => {
    if (!props.disabled) {
      // Emit with type safety
      emit('button-clicked', { timestamp: Date.now(), label: props.label });
      emit('custom-event', { data: 'some data' });
    }
  };

  return html`
    <button :disabled="${props.disabled}" @click="${handleClick}">
      ${props.label}
    </button>
  `;
});

Listening to Events

// In another component or JavaScript
const button = document.querySelector('interactive-button');
button.addEventListener('button-clicked', (event) => {
  console.log('Button clicked:', event.detail);
});

🔁 Lifecycle Hooks

The functional API provides lifecycle hooks through context-based hooks, allowing you to respond to component lifecycle events:

component('lifecycle-demo', () => {
  const emit = useEmit();

  const props = useProps({ data: [] });

  // Set up lifecycle hooks
  useOnConnected(() => {
    console.log('Component mounted to DOM');
    // Initialize external resources, start timers, etc.
    emit('component-ready');
  });

  useOnDisconnected(() => {
    console.log('Component removed from DOM');
    // Clean up resources, stop timers, etc.
    emit('component-destroyed');
  });

  useOnAttributeChanged((name, oldValue, newValue) => {
    console.log(
      `Attribute '${name}' changed from '${oldValue}' to '${newValue}'`,
    );
    if (name === 'data') {
      emit('data-attribute-changed', { oldValue, newValue });
    }
  });

  useOnError((error) => {
    console.error('Component error:', error);
    emit('component-error', { error: error.message });
  });

  return html`
    <div class="lifecycle-demo">
      <h3>Lifecycle Demo</h3>
      <p>Data items: ${props.data.length}</p>
      <ul>
        ${props.data.map((item) => html`<li>${item}</li>`)}
      </ul>
    </div>
  `;
});

Lifecycle Hook Details

  • useOnConnected(callback): Called when the component is inserted into the DOM
  • useOnDisconnected(callback): Called when the component is removed from the DOM
  • useOnAttributeChanged(callback): Called when any attribute on the element changes
  • useOnError(callback): Called when an error occurs during rendering or lifecycle events
// Example with external API integration
component('api-data', () => {
  const props = useProps({ endpoint: '/api/data' });
  const emit = useEmit();
  const data = ref(null);
  const loading = ref(false);
  let abortController: AbortController | null = null;

  const fetchData = async () => {
    abortController = new AbortController();
    loading.value = true;

    try {
      const response = await fetch(props.endpoint, {
        signal: abortController.signal,
      });
      data.value = await response.json();
      emit('data-loaded', data.value);
    } catch (error) {
      if (error.name !== 'AbortError') {
        emit('data-error', error);
      }
    } finally {
      loading.value = false;
    }
  };

  useOnConnected(() => {
    fetchData();
  });

  useOnDisconnected(() => {
    if (abortController) {
      abortController.abort();
    }
  });

  useOnError((error) => {
    console.error('API component error:', error);
    loading.value = false;
  });

  return html`
    <div class="api-data">
      ${when(loading.value, html`<p>Loading...</p>`)}
      ${when(
        data.value,
        html` <pre>${JSON.stringify(data.value, null, 2)}</pre> `,
      )}
    </div>
  `;
});

🧾 Working with HTML entities and raw HTML

The runtime escapes interpolated values by default to keep the DOM safe from XSS. Two utilities are available when you need finer control:

  • decodeEntities(str) — a small helper to decode common HTML entities (e.g. &lt;, &gt;, &amp;, numeric references) into their character equivalents.
  • unsafeHTML(htmlString) — an opt-in marker for inserting raw HTML into the template. Use this only with trusted HTML.

When to use each

  • Use decodeEntities when you receive encoded HTML-like strings from a trusted source and want to display the decoded text in a text node (without interpreting it as markup).
  • Use unsafeHTML only when you control or sanitize the HTML yourself and intentionally want the runtime to parse and insert DOM nodes from an HTML string.

Examples

import {
  html,
  unsafeHTML,
  decodeEntities,
} from '@jasonshimmy/custom-elements-runtime';

// Literal entity decoding inside template text (the compiler decodes literal template text automatically)
const vnode = html`<p>This template literal contains &lt;escaped&gt; text</p>`;

// Interpolated values are preserved as-is. If you receive an encoded string, decode explicitly:
const encoded = '&lt;3 &amp; hi';
const decoded = decodeEntities(encoded); // '<3 & hi'
const vnode2 = html`<p>${decoded}</p>`; // renders text '<3 & hi'

// Insert raw HTML (opt-in):
const raw = '<b>Important</b> <i>note</i>';
const vnode3 = html`<div>${unsafeHTML(raw)}</div>`; // inserts <b> and <i> elements as nodes

Security note ⚠️

Inserting or rendering raw HTML can open your application to XSS vulnerabilities. The runtime never inserts raw HTML unless you explicitly opt into it using unsafeHTML. Always sanitize or otherwise validate any HTML that originates from users or untrusted sources before passing it to unsafeHTML.

If you only want to display encoded HTML-like text (for example, to show <script> literally in UI), prefer decoding with decodeEntities and rendering as plain text nodes, not raw HTML.

🧬 Reactive State Management

External State

Create reactive state outside components that can be shared:

// Shared reactive state
const userState = ref({
  name: 'John',
  email: 'john@example.com',
  preferences: {
    theme: 'dark',
    notifications: true,
  },
});

// Computed values
const displayName = computed(() => userState.value.name || 'Anonymous');

// Watchers
watch(
  () => userState.value.email,
  (newEmail, oldEmail) => {
    console.log(`Email changed from ${oldEmail} to ${newEmail}`);
  },
);

// Components can access and modify shared state
component('user-profile', () => {
  const emit = useEmit();

  const updateName = (newName: string) => {
    userState.value.name = newName;
    emit('name-updated', { name: newName });
  };

  return html`
    <div>
      <h2>${displayName.value}</h2>
      <input
        type="text"
        :value="${userState.value.name}"
        @input="${(e) => updateName(e.target.value)}"
        placeholder="Enter your name"
      />
      <p>Email: ${userState.value.email}</p>
    </div>
  `;
});

Component-Scoped State

For component-specific state, create reactive state within the component:

component('counter', () => {
  const props = useProps({ initialValue: 0, step: 1 });
  const emit = useEmit();

  // Component-scoped reactive state
  const count = ref(props.initialValue);
  const increment = () => {
    count.value += props.step;
    emit('count-changed', { count: count.value });
  };

  const decrement = () => {
    count.value -= props.step;
    emit('count-changed', { count: count.value });
  };

  return html`
    <div class="counter">
      <button @click="${decrement}">-</button>
      <span class="count">${count.value}</span>
      <button @click="${increment}">+</button>
    </div>
  `;
});

🎛️ Directives and Bindings

The streamlined API works seamlessly with all existing directives and bindings:

Property Binding (:prop)

Bind reactive values to element properties and attributes:

component('dynamic-input', () => {
  const emit = useEmit();
  const props = useProps({
    type: 'text',
    value: '',
    placeholder: 'Enter text...',
    disabled: false,
  });

  return html`
    <input
      :type="${props.type}"
      :value="${props.value}"
      :placeholder="${props.placeholder}"
      :disabled="${props.disabled}"
      @input="${(e) => emit('input', e.target.value)}"
    />
  `;
});

Guaranteed Property Assignment (:bind)

For complex objects, functions, or when you need to ensure JavaScript property assignment:

component('complex-props', () => {
  const emit = useEmit();
  const props = useProps({
    config: {},
    items: [] as any[],
    onItemClick: null as ((item: any) => void) | null,
  });

  return html`
    <custom-element
      :bind="${{
        config: props.config,
        items: props.items,
        onItemClick:
          props.onItemClick || ((item) => emit('item-clicked', item)),
      }}"
    ></custom-element>
  `;
});

Note: :bind prefers to assign values as JavaScript properties (not attributes) so complex objects and functions are passed as real JS values to the child. A few caveats: data-*, aria-*, and class keys remain attributes for reliable HTML serialization; native controls (inputs/selects/textareas/buttons) treat disabled specially and may prefer attribute semantics unless the value is clearly boolean or a reactive/wrapper; if property assignment throws the runtime will fall back to setAttribute() with a serialized value. :bind also accepts a string expression (evaluated in the render context) which can produce an object to be bound the same way.

Class Binding (:class)

Dynamic class management with object and array syntax:

component('status-card', () => {
  const props = useProps({
    status: 'normal',
    size: 'medium',
    interactive: false,
  });
  const emit = useEmit();
  return html`
    <div
      :class="${{
        'status-card': true,
        [`status-${props.status}`]: true,
        [`size-${props.size}`]: true,
        interactive: props.interactive,
        clickable: props.interactive,
      }}"
      @click="${props.interactive ? () => emit('card-clicked') : null}"
    >
      <slot></slot>
    </div>
  `;
});

Style Binding (:style)

Dynamic inline styles with object and string syntax:

component('progress-bar', () => {
  const props = useProps({
    progress: 0,
    color: '#007bff',
    height: '8px',
    animated: false,
  });
  return html`
    <div class="progress-container" :style="${{ height: props.height }}">
      <div
        class="progress-bar"
        :class="${{ animated: props.animated }}"
        :style="${{
          width: `${Math.min(100, Math.max(0, props.progress))}%`,
          backgroundColor: props.color,
          transition: props.animated ? 'width 0.3s ease' : 'none',
        }}"
      ></div>
    </div>
  `;
});

Two-Way Binding (:model)

The :model directive provides automatic two-way data binding for form elements. With the functional API, you can bind directly to reactive state objects:

component('form-field', () => {
  const emit = useEmit();
  const props = useProps({
    label: 'Name',
    type: 'text',
    initialValue: '',
  });
  const value = ref(props.initialValue);

  watch(value, (newVal) => emit('value-changed', newVal));

  return html`
    <label class="form-field">
      ${props.label}
      <input type="${props.type}" :model="${value}" />
      <p>Current value: ${value.value}</p>
    </label>
  `;
});

Controlled Component Pattern

For custom elements that need to work with parent components using props:

component('controlled-input', () => {
  const emit = useEmit();
  const props = useProps({
    modelValue: '',
    label: '',
  });

  return html`
    <label class="controlled-input">
      ${props.label}
      <input
        value="${props.modelValue}"
        @input="${(e: Event) =>
          emit('update:modelValue', (e.target as HTMLInputElement).value)}"
      />
    </label>
  `;
});

Custom Model Binding (:model:prop)

Custom property binding for complex components using reactive state:

component('multi-select', () => {
  const emit = useEmit();
  const props = useProps({
    options: [] as { label: string; value: string }[],
    selectedValues: [] as string[],
  });

  const isSelected = (value: string) => props.selectedValues.includes(value);
  const toggleSelection = (value: string) => {
    const newSelection = isSelected(value)
      ? props.selectedValues.filter((item: string) => item !== value)
      : [...props.selectedValues, value];
    // Emit update event so parent can update selectedValues via :model or binding
    emit('update:selectedValues', newSelection);
    emit('selection-changed', newSelection);
  };

  return html`
    <div class="multi-select">
      ${each(
        props.options,
        (option) => html`
          <label class="option">
            <input
              type="checkbox"
              checked="${isSelected(option.value)}"
              @change="${() => toggleSelection(option.value)}"
            />
            ${option.label}
          </label>
        `,
      )}
      <p>Selected: ${props.selectedValues.join(', ')}</p>
    </div>
  `;
});

Event Binding (@event)

Comprehensive event handling with modifiers:

component('event-demo', () => {
  const props = useProps({ disabled: false });
  const emit = useEmit();

  const handleKeydown = (e: KeyboardEvent) => {
    if (e.key === 'Enter') {
      emit('enter-pressed', { value: (e.target as HTMLInputElement).value });
    } else if (e.key === 'Escape') {
      emit('escape-pressed');
    }
  };

  const handleSubmit = (e: Event) => {
    e.preventDefault();
    if (!props.disabled) {
      const formData = new FormData(e.target as HTMLFormElement);
      emit('form-submitted', Object.fromEntries(formData));
    }
  };

  return html`
    <form @submit="${handleSubmit}">
      <input
        type="text"
        :disabled="${props.disabled}"
        @keydown="${handleKeydown}"
        @focus="${() => emit('input-focused')}"
        @blur="${() => emit('input-blurred')}"
        @input="${(e) => emit('input-changed', e.target.value)}"
      />
      <button
        type="submit"
        :disabled="${props.disabled}"
        @click="${() => emit('submit-clicked')}"
      >
        Submit
      </button>
    </form>
  `;
});

Reference Binding (ref)

Access DOM elements directly:

component('focusable-input', () => {
  const props = useProps({ autoFocus: false });
  const emit = useEmit();
  let inputRef: HTMLInputElement | null = null;

  const focusInput = () => {
    if (inputRef) {
      inputRef.focus();
      emit('input-focused');
    }
  };

  const clearInput = () => {
    if (inputRef) {
      inputRef.value = '';
      inputRef.focus();
      emit('input-cleared');
    }
  };

  return html`
    <div class="input-container">
      <input
        :ref="${(el) => {
          inputRef = el;
          if (props.autoFocus) el.focus();
        }}"
        type="text"
        @input="${(e) => emit('input-changed', e.target.value)}"
      />
      <button @click="${focusInput}">Focus</button>
      <button @click="${clearInput}">Clear</button>
    </div>
  `;
});

Notes about :ref

  • Supported forms:

    • Reactive ref objects (e.g. const r = ref(null)) — the runtime will assign the element to r.value.
    • Callback refs (functions) — the runtime will call the function with the element when assigned.
    • String refs (legacy) — the runtime stores the element in the component's internal refs map as refs['name'] = element.
  • Lifecycle & cleanup:

    • Reactive refs are assigned during render and are available by the time connected hooks (for example useOnConnected) run. They are not automatically nulled on unmount — clear them yourself in a disconnect hook if you need null to indicate cleanup.
    • String refs are removed from the internal refs map when nodes are cleaned up by the runtime.
    • Callback refs are invoked on assignment but are not automatically called with null on cleanup.
  • Browser-only: :ref only makes sense in a DOM environment — on the server there is no element to assign.

🔀 Conditional Rendering and Lists

Using when for Conditional Rendering

component('conditional-content', () => {
  const props = useProps({
    isLoggedIn: false,
    userRole: 'guest',
    showAdvanced: false,
  });
  const emit = useEmit();

  return html`
    <div>
      ${when(
        props.isLoggedIn,
        html`
          <h2>Welcome back!</h2>
          <p>Role: ${props.userRole}</p>
          ${when(
            props.userRole === 'admin',
            html`
              <button @click="${() => emit('admin-action')}">
                Admin Panel
              </button>
            `,
          )}
          ${when(
            props.showAdvanced,
            html`
              <div class="advanced-settings">
                <h3>Advanced Settings</h3>
                <!-- Advanced content -->
              </div>
            `,
          )}
        `,
      )}
      ${when(
        !props.isLoggedIn,
        html`
          <div class="login-prompt">
            <h2>Please log in</h2>
            <button @click="${() => emit('login-requested')}">Login</button>
          </div>
        `,
      )}
    </div>
  `;
});

Note: The when directive only accepts a condition and content. For if/else logic, use two separate when calls or the match directive below.

💤 Lazy when (runtime-only)

If your conditional content includes expressions that are expensive or that may throw when evaluated, prefer the lazy factory overload which defers building the child VNode(s) until the condition becomes truthy:

// Use a factory to avoid evaluating `expensive()` while `isVisible` is falsy
${when(isVisible, () => html`<div>${expensive()}</div>`) }

Key points:

  • This behavior is implemented entirely at runtime. There is no compile-time transform required or used.
  • Existing code using when(cond, html...) continues to work. Switch to the factory form when you need guarded evaluation.
  • The factory will only be executed when the condition is truthy. The runtime ensures stable anchor blocks so DOM updates remain predictable.
Practical example: guarding a parse
component('safe-render', () => {
  const props = useProps({ jsonText: '' });

  return html`
    ${when(props.jsonText, () => {
      // parse may throw — run only when jsonText is present
      const data = JSON.parse(props.jsonText);
      return html`<pre>${JSON.stringify(data, null, 2)}</pre>`;
    })}
  `;
});
Anchor normalization and falsy children

The runtime preserves intentional falsy children inside conditional blocks. Values like 0, false, and '' are valid child nodes and will be rendered. Only null and undefined are treated as absent children and are filtered out when normalizing anchor block children.

Using match for Complex Conditionals

component('status-indicator', () => {
  const props = useProps({
    status: 'pending' as 'pending' | 'success' | 'error' | 'warning',
    message: '',
  });
  const emit = useEmit();

  return html`
    <div class="status-indicator">
      ${match()
        .when(
          props.status === 'pending',
          html`
            <div class="pending">
              <span class="icon"></span>
              <span>Processing...</span>
            </div>
          `,
        )
        .when(
          props.status === 'success',
          html`
            <div class="success">
              <span class="icon"></span>
              <span>Success: ${props.message}</span>
            </div>
          `,
        )
        .when(
          props.status === 'error',
          html`
            <div class="error">
              <span class="icon"></span>
              <span>Error: ${props.message}</span>
              <button @click="${() => emit('retry')}">Retry</button>
            </div>
          `,
        )
        .when(
          props.status === 'warning',
          html`
            <div class="warning">
              <span class="icon">⚠️</span>
              <span>Warning: ${props.message}</span>
            </div>
          `,
        )
        .otherwise(html`
          <div class="unknown">
            <span>Unknown status: ${props.status}</span>
          </div>
        `)
        .done()}
    </div>
  `;
});

Lazy match branches

match() supports the same runtime factory pattern as when. Provide a factory to defer creating branch content until the branch is selected:

const node = match()
  .when(condA, () => html`<div>${expensiveA()}</div>`) // not called until condA is truthy
  .when(condB, html`<div>simple</div>`) // pre-built
  .done();

Using each for List Rendering

component('todo-list', () => {
  const props = useProps({
    todos: [] as Array<{ id: string; text: string; completed: boolean }>,
    filter: 'all' as 'all' | 'active' | 'completed',
  });
  const emit = useEmit();

  const filteredTodos = computed(() => {
    switch (props.filter) {
      case 'active':
        return props.todos.filter((todo) => !todo.completed);
      case 'completed':
        return props.todos.filter((todo) => todo.completed);
      default:
        return props.todos;
    }
  });

  const toggleTodo = (id: string) => {
    emit('toggle-todo', { id });
  };

  const deleteTodo = (id: string) => {
    emit('delete-todo', { id });
  };

  return html`
    <div class="todo-list">
      <div class="filters">
        <button
          :class="${{ active: props.filter === 'all' }}"
          @click="${() => emit('filter-changed', 'all')}"
        >
          All
        </button>
        <button
          :class="${{ active: props.filter === 'active' }}"
          @click="${() => emit('filter-changed', 'active')}"
        >
          Active
        </button>
        <button
          :class="${{ active: props.filter === 'completed' }}"
          @click="${() => emit('filter-changed', 'completed')}"
        >
          Completed
        </button>
      </div>

      <ul class="todo-items">
        ${each(
          filteredTodos.value,
          (todo) => html`
            <li key="${todo.id}" :class="${{ completed: todo.completed }}">
              <input
                type="checkbox"
                :checked="${todo.completed}"
                @change="${() => toggleTodo(todo.id)}"
              />
              <span class="todo-text">${todo.text}</span>
              <button class="delete-btn" @click="${() => deleteTodo(todo.id)}">
                Delete
              </button>
            </li>
          `,
        )}
      </ul>

      ${when(
        filteredTodos.value.length === 0,
        html` <p class="empty-state">No todos found</p> `,
      )}
    </div>
  `;
});

🎨 Styling

Static Styles

import {
  component,
  html,
  css,
  useStyle,
} from '@jasonshimmy/custom-elements-runtime';

component('styled-button', () => {
  const props = useProps({
    variant: 'primary' as 'primary' | 'secondary' | 'danger',
    size: 'medium' as 'small' | 'medium' | 'large',
  });
  const emit = useEmit();

  useStyle(
    () => css`
      :host {
        display: inline-block;
      }

      .btn {
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-family: inherit;
        transition: all 0.2s ease;
      }

      .btn-small {
        padding: 4px 8px;
        font-size: 12px;
      }
      .btn-medium {
        padding: 8px 16px;
        font-size: 14px;
      }
      .btn-large {
        padding: 12px 24px;
        font-size: 16px;
      }

      .btn-primary {
        background: #007bff;
        color: white;
      }

      .btn-secondary {
        background: #6c757d;
        color: white;
      }

      .btn-danger {
        background: #dc3545;
        color: white;
      }

      .btn:hover {
        opacity: 0.9;
        transform: translateY(-1px);
      }
    `,
  );

  return html`
    <button
      class="btn btn-${props.variant} btn-${props.size}"
      @click="${() => emit('click')}"
    >
      <slot></slot>
    </button>
  `;
});

Dynamic Styles

component('themed-card', () => {
  const props = useProps({
    theme: 'light' as 'light' | 'dark',
    accentColor: '#007bff',
  });
  const emit = useEmit();

  useStyle(
    () => css`
      :host {
        display: block;
        --accent-color: ${props.accentColor};
        --bg-color: ${props.theme === 'dark' ? '#2d3748' : '#ffffff'};
        --text-color: ${props.theme === 'dark' ? '#e2e8f0' : '#2d3748'};
        --border-color: ${props.theme === 'dark' ? '#4a5568' : '#e2e8f0'};
      }

      .card {
        background: var(--bg-color);
        color: var(--text-color);
        border: 1px solid var(--border-color);
        border-radius: 8px;
        overflow: hidden;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      }

      .card-header {
        padding: 16px;
        border-bottom: 1px solid var(--border-color);
        background: linear-gradient(
          135deg,
          var(--accent-color),
          color-mix(in srgb, var(--accent-color) 80%, white)
        );
        color: white;
      }

      .card-content {
        padding: 16px;
      }
    `,
  );

  return html`
    <div class="card">
      <div class="card-header">
        <slot name="header"></slot>
      </div>
      <div class="card-content">
        <slot></slot>
      </div>
    </div>
  `;
});

Using the useStyle Hook

The useStyle hook provides a powerful way to apply styles reactively based on component props and state:

✨ Key Features

  • 🔄 Reactive Styling: Styles automatically update when props or state change
  • 🎯 Type Safety: Full TypeScript inference for CSS values
  • ⚡ Performance: Efficient updates only when dependencies change
  • 🧹 Scoped: Styles are automatically scoped to the component
  • 💡 Intuitive: Familiar CSS template literal syntax

Implementation notes

  • useStyle is a render-time hook: the callback you provide is executed during component render so any reactive reads inside will be tracked. The callback should return a CSS string (commonly produced via the css template helper). The runtime stores that computed CSS string on the component render context and applies it as part of the component's stylesheet update step.
  • The runtime prefers to apply styles using constructable CSSStyleSheet (adoptedStyleSheets) when available and falls back to injecting a single <style data-cer-runtime> element in the shadow root for environments that don't support constructable stylesheets (or when creation fails). Tests and some environments may also see a stubbed adoptedStyleSheets array for compatibility.
  • The runtime no longer expects or supports a useStyle flavor that receives the host element as an argument (i.e. useStyle((el) => ...)). The canonical API is useStyle(() => css...) which returns a CSS string.
  • SSR / non-DOM environments: useStyle still runs during server-side discovery or render when possible, but constructable stylesheet behavior is platform-dependent. The runtime uses safe fallbacks to avoid throwing in SSR.

📖 Basic Usage

component('styled-component', () => {
  const props = useProps({ color: 'blue' });

  useStyle(
    () => css`
      :host {
        background-color: ${props.color};
        border: 1px solid ${props.color};
      }
    `,
  );

  return html`<div>Styled content</div>`;
});

🔄 Reactive Styles with State

component('interactive-button', () => {
  const props = useProps({ initialColor: 'blue' });
  const buttonState = ref({ color: props.initialColor, hovered: false });

  useStyle(
    () => css`
      :host {
        --button-color: ${buttonState.value.color};
        --opacity: ${buttonState.value.hovered ? '0.8' : '1'};
      }

      .button {
        background: var(--button-color);
        opacity: var(--opacity);
        transition: all 0.2s ease;
        border: none;
        padding: 12px 24px;
        border-radius: 4px;
        color: white;
        cursor: pointer;
      }
    `,
  );

  return html`
    <button
      class="button"
      @mouseenter="${() =>
        (buttonState.value = { ...buttonState.value, hovered: true })}"
      @mouseleave="${() =>
        (buttonState.value = { ...buttonState.value, hovered: false })}"
      @click="${() =>
        (buttonState.value = {
          ...buttonState.value,
          color: buttonState.value.color === 'blue' ? 'green' : 'blue',
        })}"
    >
      Toggle Color
    </button>
  `;
});

🎨 Complex Styling Logic

component('adaptive-card', () => {
  const props = useProps({
    theme: 'light' as 'light' | 'dark',
    size: 'medium' as 'small' | 'medium' | 'large',
    highlighted: false,
  });
  const cardState = ref({ expanded: false });

  useStyle(() => {
    const isDark = props.theme === 'dark';
    const isLarge = props.size === 'large';
    const isHighlighted = props.highlighted;

    return css`
      :host {
        display: block;
        transition: all 0.3s ease;
        transform: ${cardState.value.expanded ? 'scale(1.02)' : 'scale(1)'};
      }

      .card {
        background: ${isDark ? '#2d3748' : '#ffffff'};
        color: ${isDark ? '#e2e8f0' : '#2d3748'};
        border: 2px solid
          ${isHighlighted ? '#007bff' : isDark ? '#4a5568' : '#e2e8f0'};
        border-radius: ${isLarge ? '12px' : '8px'};
        padding: ${isLarge ? '24px' : '16px'};
        box-shadow: ${isHighlighted
          ? '0 4px 12px rgba(0,123,255,0.3)'
          : '0 2px 4px rgba(0,0,0,0.1)'};
        font-size: ${isLarge ? '18px' : '14px'};
      }

      .card:hover {
        box-shadow: ${isHighlighted
          ? '0 6px 20px rgba(0,123,255,0.4)'
          : '0 4px 8px rgba(0,0,0,0.2)'};
      }
    `;
  });

  return html`
    <div
      class="card"
      @click="${() =>
        (cardState.value = {
          ...cardState.value,
          expanded: !cardState.value.expanded,
        })}"
    >
      <slot></slot>
    </div>
  `;
});

Styling Best Practices

  1. Use CSS Custom Properties: For complex themes and design tokens
  2. Leverage Template Literals: For dynamic CSS values
  3. Keep Logic Simple: Extract complex styling logic to separate functions
  4. Performance: useStyle is reactive - styles update only when dependencies change
// Good: Using CSS custom properties for consistency
component('themed-component', () => {
  const props = useProps({ primaryColor: '#007bff' });

  useStyle(
    () => css`
      :host {
        --primary: ${props.primaryColor};
        --primary-hover: color-mix(in srgb, var(--primary) 80%, black);
        --primary-light: color-mix(in srgb, var(--primary) 20%, white);
      }

      .button {
        background: var(--primary);
      }
      .button:hover {
        background: var(--primary-hover);
      }
      .badge {
        background: var(--primary-light);
      }
    `,
  );
});

🔗 Component Communication

Parent-Child Communication

// Child component
component('form-input', () => {
  const props = useProps({
    label: '',
    value: '',
    type: 'text',
    required: false,
    error: '',
  });
  const emit = useEmit();

  const handleInput = (e: Event) => {
    const input = e.target as HTMLInputElement;
    emit('update:value', input.value);

    // Emit validation event
    if (props.required && !input.value.trim()) {
      emit('validation-error', 'This field is required');
    } else {
      emit('validation-success');
    }
  };

  return html`
    <div class="form-group">
      <label class="form-label">
        ${props.label}
        ${when(props.required, html`<span class="required">*</span>`)}
      </label>
      <input
        :type="${props.type}"
        :value="${props.value}"
        :required="${props.required}"
        :class="${{ error: !!props.error }}"
        @input="${handleInput}"
        @blur="${handleInput}"
      />
      ${when(
        props.error,
        html` <span class="error-message">${props.error}</span> `,
      )}
    </div>
  `;
});

// Parent component
component('contact-form', () => {
  const emit = useEmit();
  const formData = ref({
    name: '',
    email: '',
    message: '',
  });

  const errors = ref({
    name: '',
    email: '',
    message: '',
  });

  const handleInputChange = (field: string, value: string) => {
    formData.value[field] = value;
    // Clear error when user starts typing
    if (errors.value[field]) {
      errors.value[field] = '';
    }
  };

  const handleValidationError = (field: string, error: string) => {
    errors.value[field] = error;
  };

  const submitForm = () => {
    // Validate all fields
    const hasErrors = Object.values(errors.value).some((error) => error);
    if (!hasErrors) {
      emit('form-submitted', formData.value);
    }
  };

  return html`
    <form
      @submit="${(e) => {
        e.preventDefault();
        submitForm();
      }}"
    >
      <form-input
        label="Name"
        :value="${formData.value.name}"
        required="true"
        :error="${errors.value.name}"
        @update:value="${(value) => handleInputChange('name', value)}"
        @validation-error="${(error) => handleValidationError('name', error)}"
        @validation-success="${() => handleValidationError('name', '')}"
      ></form-input>

      <form-input
        label="Email"
        type="email"
        :value="${formData.value.email}"
        required="true"
        :error="${errors.value.email}"
        @update:value="${(value) => handleInputChange('email', value)}"
        @validation-error="${(error) => handleValidationError('email', error)}"
        @validation-success="${() => handleValidationError('email', '')}"
      ></form-input>

      <form-input
        label="Message"
        :value="${formData.value.message}"
        required="true"
        :error="${errors.value.message}"
        @update:value="${(value) => handleInputChange('message', value)}"
        @validation-error="${(error) =>
          handleValidationError('message', error)}"
        @validation-success="${() => handleValidationError('message', '')}"
      ></form-input>

      <button type="submit">Send Message</button>
    </form>
  `;
});

Global State Management

// Global state store
const appState = ref({
  user: null,
  notifications: [],
  theme: 'light',
});

// Global actions
const userActions = {
  login: (userData: any) => {
    appState.value.user = userData;
    eventBus.emit('user:login', userData);
  },
  logout: () => {
    appState.value.user = null;
    eventBus.emit('user:logout');
  },
  addNotification: (notification: any) => {
    appState.value.notifications.push({
      id: Date.now(),
      ...notification,
    });
  },
};

// Components can access global state
component('user-avatar', () => {
  const emit = useEmit();

  return html`
    <div class="user-avatar">
      ${when(
        appState.value.user,
        html`
          <img
            src="${appState.value.user.avatar}"
            alt="${appState.value.user.name}"
            @click="${() => emit('profile-clicked')}"
          />
          <span>${appState.value.user.name}</span>
        `,
      )}
      ${when(
        !appState.value.user,
        html`
          <button @click="${() => emit('login-requested')}">Login</button>
        `,
      )}
    </div>
  `;
});

component('notification-center', () => {
  const emit = useEmit();

  const dismissNotification = (id: number) => {
    appState.value.notifications = appState.value.notifications.filter(
      (n) => n.id !== id,
    );
  };

  return html`
    <div class="notification-center">
      ${when(
        appState.value.notifications.length > 0,
        html`
          <div class="notifications">
            ${each(
              appState.value.notifications,
              (notification) => html`
                <div
                  key="${notification.id}"
                  class="notification notification-${notification.type}"
                >
                  <span>${notification.message}</span>
                  <button
                    @click="${() => dismissNotification(notification.id)}"
                  >
                    ×
                  </button>
                </div>
              `,
            )}
          </div>
        `,
      )}
    </div>
  `;
});

⚙️ Advanced Configuration

Component with Lifecycle Hooks and Styling

The functional API uses hooks for all component features:

component('advanced-component', () => {
  const props = useProps({ data: [] as any[] });
  const emit = useEmit();

  // Set up lifecycle hooks
  useOnConnected(() => {
    console.log('Component connected to DOM');
  });

  useOnDisconnected(() => {
    console.log('Component disconnected from DOM');
  });

  useOnError((error: Error) => {
    console.error('Component error:', error);
  });

  // Apply custom styling
  useStyle(
    () => css`
      :host {
        display: block;
        padding: 16px;
      }
    `,
  );

  return html`<div>${props.data.length} items</div>`;
});

⏳ Async Components

defineAsyncComponent — lazy-loaded components

defineAsyncComponent registers a custom element whose render function is loaded asynchronously. Use it for heavy components that should not block the initial render.

import { defineAsyncComponent, html } from '@jasonshimmy/custom-elements-runtime';

defineAsyncComponent(
  'heavy-chart',
  () => import('./chart-impl').then(m => m.renderFn),
  {
    loading: () => html`<p>Loading chart…</p>`,   // shown while pending
    error:   () => html`<p>Chart unavailable.</p>`, // shown on failure
    timeout: 5000,                                   // ms before showing error
  },
)

The element transitions through four states:

State Description
idle / loading Loader promise is pending. The loading template is rendered if provided.
resolved Loader fulfilled. The returned render function is called.
error / timeout Loader rejected or timeout exceeded. The error template is rendered.

Loader return value: The promise must resolve with a render function () => VNode | VNode[]. The render function runs inside the component's reactive context — useProps, ref, etc. are all available.

Timeout: If timeout is set and the loader does not resolve within that many milliseconds the element moves to the error state and renders the error template.

See async-components.md for the full API reference.


Inline async state (manual pattern)

For simpler cases where you want to manage loading state yourself inside a single component — without code-splitting — use a reactive ref pattern:

component('async-data', () => {
  const props = useProps({ userId: '' });
  const emit = useEmit();
  const loading = ref(false);
  const data = ref(null);
  const error = ref(null);

  const fetchData = async () => {
    if (!props.userId) return;

    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(`/api/users/${props.userId}`);
      if (!response.ok) throw new Error('Failed to fetch user');

      data.value = await response.json();
      emit('data-loaded', data.value);
    } catch (err) {
      error.value = err.message;
      emit('data-error', err);
    } finally {
      loading.value = false;
    }
  };

  // Watch for userId changes
  watch(() => props.userId, fetchData, { immediate: true });

  return html`
    <div class="async-data">
      ${when(
        loading.value,
        html` <div class="loading-spinner">Loading user data...</div> `,
      )}
      ${when(
        error.value,
        html`
          <div class="error-state">
            <p>Error: ${error.value}</p>
            <button @click="${fetchData}">Retry</button>
          </div>
        `,
      )}
      ${when(
        data.value && !loading.value,
        html`
          <div class="user-data">
            <h2>${data.value.name}</h2>
            <p>${data.value.email}</p>
            <p>
              Joined: ${new Date(data.value.createdAt).toLocaleDateString()}
            </p>
          </div>
        `,
      )}
    </div>
  `;
});

🧪 Testing Components

The functional API is straightforward to test with any DOM-capable test runner (Vitest + happy-dom is the recommended pairing). See Testing Guide for a complete reference including lifecycle, async components, and event testing.

Key tips:

  • Use flushDOMUpdates() instead of setTimeout for deterministic, synchronous DOM flushing.
  • Register each component once — use a unique tag name per test or a shared beforeAll setup.
  • Query into element.shadowRoot to inspect rendered output.
import { describe, it, expect, beforeEach } from 'vitest';
import {
  component,
  html,
  useProps,
  useEmit,
  flushDOMUpdates,
} from '@jasonshimmy/custom-elements-runtime';

describe('Streamlined Component', () => {
  beforeEach(() => {
    document.body.innerHTML = '';
  });

  it('should render with reactive props', () => {
    component('test-component', () => {
      const props = useProps({
        message: 'default',
        count: 0,
      });
      const emit = useEmit();

      return html`
        <div>
          <span class="message">${props.message}</span>
          <span class="count">${props.count}</span>
          <button @click="${() => emit('increment')}">+</button>
        </div>
      `;
    });

    const element = document.createElement('test-component');
    element.setAttribute('message', 'Hello World');
    element.setAttribute('count', '5');
    document.body.appendChild(element);

    flushDOMUpdates();

    const messageEl = element.shadowRoot?.querySelector('.message');
    const countEl = element.shadowRoot?.querySelector('.count');

    expect(messageEl?.textContent).toBe('Hello World');
    expect(countEl?.textContent).toBe('5');
  });

  it('should emit events correctly', () => {
    let emittedData = null;

    component('emitter-test', () => {
      const emit = useEmit();

      return html`
        <button @click="${() => emit('test-event', { data: 'test' })}">
          Click
        </button>
      `;
    });

    const element = document.createElement('emitter-test');
    element.addEventListener('test-event', (e) => {
      emittedData = e.detail;
    });

    document.body.appendChild(element);
    flushDOMUpdates();

    const button = element.shadowRoot?.querySelector('button');
    button?.click();

    expect(emittedData).toEqual({ data: 'test' });
  });
});

🎯 Best Practices

1. Use Descriptive Props with Defaults

// ✅ Good
component('user-badge', () => {
  const props = useProps({
    name: 'Anonymous',
    role: 'user' as 'user' | 'admin' | 'moderator',
    showAvatar: true,
    size: 'medium' as 'small' | 'medium' | 'large',
  });
  const emit = useEmit();

  // ...
});

// ❌ Avoid
component('user-badge', () => {
  // Hard to understand
  const props = useProps({ a: '', b: '', c: '' });
});

2. Prefer External State for Shared Data

// ✅ Good - Shared state
const userPreferences = ref({
  theme: 'light',
  language: 'en',
});

component('theme-switcher', () => {
  const emit = useEmit();
  // Use shared state
});

component('language-selector', () => {
  const emit = useEmit();
  // Use same shared state
});

3. Use Computed Values for Derived State

// ✅ Good
const items = ref([]);
const filteredItems = computed(() => items.value.filter((item) => item.active));
const itemCount = computed(() => filteredItems.value.length);

// ❌ Avoid - Manual synchronization
const items = ref([]);
const itemCount = ref(0);
// Manually updating itemCount everywhere items changes

4. Emit Semantic Events

// ✅ Good - Semantic event names
emit('user-selected', { userId: 123 });
emit('form-submitted', { formData });
emit('validation-failed', { errors });

// ❌ Avoid - Generic event names
emit('click', data);
emit('change', data);
emit('event', data);

5. Handle Errors Gracefully

component('data-loader', () => {
  const props = useProps({ url: '' });
  const emit = useEmit();
  const loading = ref(false);
  const error = ref(null);

  const loadData = async () => {
    try {
      loading.value = true;
      error.value = null;
      // Load data...
    } catch (err) {
      error.value = err.message;
      emit('load-error', err);
    } finally {
      loading.value = false;
    }
  };

  return html`
    ${when(
      error.value,
      html`
        <div class="error">
          Error: ${error.value}
          <button @click="${loadData}">Retry</button>
        </div>
      `,
    )}
    <!-- Rest of template -->
  `;
});

🎉 Summary

The streamlined functional component API provides:

  • 🎯 Zero Configuration - No complex setup required
  • ⚡ Automatic Reactivity - Props are reactive by default
  • 🔒 Type Safety - Full TypeScript support with inference
  • 🚀 Better Performance - Optimized prop parsing and reactivity
  • 💡 Developer Experience - Intuitive, familiar patterns
  • 🔄 Full Feature Support - All directives, bindings, and state management

This API eliminates the complexity of the previous system while maintaining all the power and flexibility you need to build modern, reactive custom elements.


🚀 Advanced APIs

The runtime ships several advanced utilities beyond the core functional API documented above.

⚡ Reactive Utilities

computed() — Memoized derived state with automatic cache invalidation.

watchEffect(fn) — Run a side-effect automatically whenever any reactive dependency changes.

nextTick() — Defer work until after all pending DOM updates have been flushed.

See the full Reactive API guide for usage examples.

🏝️ Provide / Inject

provide(key, value) and inject(key, defaultValue?) implement ancestor → descendant dependency injection without prop-drilling.

See the Provide / Inject guide for full documentation.

🧩 Composables

createComposable(fn) extracts reusable stateful logic — including lifecycle hooks — into shareable factory functions.

See the Composables guide for usage patterns.

🚀 Teleport

useTeleport(target) renders virtual DOM content into an arbitrary DOM node outside the current shadow root — ideal for modals, tooltips, and popovers.

See the Teleport guide for full documentation.

♻️ Keep-Alive

registerKeepAlive() registers <cer-keep-alive>, a wrapper element that preserves component JavaScript state across DOM removals and re-insertions.

See the Keep-Alive guide for full documentation.

🎯 useExpose() — Imperative Handles

useExpose(api) publishes methods and properties directly onto the host element so parent components or plain JavaScript can call them by reference — similar to defineExpose() in Vue 3 or useImperativeHandle() in React.

See the useExpose() guide for full documentation.

🧩 useSlots() — Slot Inspection

useSlots() returns helpers (has(), getNodes(), names()) to inspect which named slots have content at render time. Use it to conditionally render wrapper elements only when a slot has been filled.

See the useSlots() guide for full documentation.

🧱 Built-in Components

registerBuiltinComponents() registers <cer-suspense>, <cer-error-boundary>, and <cer-keep-alive> — opt-in utility components for loading states, graceful error recovery, and state preservation.

See the Built-in Components guide for full documentation.

⚡ Concurrent Rendering & Update Priority

scheduleWithPriority(update, priority?, componentId?) provides explicit control over when updates run: 'immediate' (synchronous), 'normal' (microtask-batched with deduplication), or 'idle' (deferred via requestIdleCallback — time-sliced and non-blocking).

See the Concurrent Rendering guide for full documentation.

🔧 Development & Logging Utilities

The runtime exports two helpers for development-time diagnostics:

setDevMode(v: boolean)

Programmatically enable or disable dev-mode console logging at runtime. This is useful when you want verbose logs during a session without changing build configuration.

import { setDevMode } from '@jasonshimmy/custom-elements-runtime';

setDevMode(true); // enable dev logs
setDevMode(false); // silence them

Alternatively, set globalThis.__CE_RUNTIME_DEV__ = true before the library is imported to enable logging as early as possible.

Note: This flag is process-wide. In Node/SSR environments it affects all requests in the same process.

devLog(message: string, ...args: unknown[])

Log an informational message to the console only when dev mode is enabled. No-op in production builds or when dev mode is disabled.

import { devLog } from '@jasonshimmy/custom-elements-runtime';

component('my-widget', () => {
  devLog('[my-widget] render()', { props }); // silent in production
  // ...
});