defineCustomElement function

void defineCustomElement(
  1. String tagName,
  2. BloomNode builder(
    1. CustomElementContext context
    ), {
  3. List<String> observedAttributes = const [],
  4. bool useShadowDom = true,
  5. String shadowMode = 'open',
  6. @internal String? hotReloadScopeId,
})

Registers a native browser Custom Element with tagName backed by a Bloom descriptor tree.

Enables authoring reusable Web Components in pure Dart and Bloom JS Native, which can then be consumed by any web application, static HTML page, or non-Bloom framework (React, Vue, Angular, Svelte, vanilla JS).

Lifecycle Integration

  • Connected Callback (connectedCallback): When the custom element connects to the DOM, it mounts the descriptor tree returned by builder via mountToElement into the host element (or a wrapper within the Shadow DOM).
  • Disconnected Callback (disconnectedCallback): When removed from the DOM, it unmounts the Bloom tree and disposes all reactive signals and effects.
  • Attribute Changed Callback (attributeChangedCallback): Attributes listed in observedAttributes are observed; when modified on the DOM element, changes are automatically pushed to the reactive signals created via CustomElementContext.attributeSignal.

Shadow DOM Encapsulation

By default, creates an open Shadow DOM (useShadowDom: true, shadowMode: 'open') and mounts the Bloom subtree into an internal wrapper <div style="display: contents;"> to maintain full style and DOM encapsulation. Set useShadowDom: false to mount directly into the light DOM of the host element.

Browser-Only Execution

defineCustomElement requires the browser's customElements registry and window. It does not execute during Server-Side Rendering (renderToHtml). For a strict Content Security Policy, set bloomScriptNonce to the nonce permitted by script-src before calling this function.

void main() {
  defineCustomElement(
    'bloom-user-badge',
    (CustomElementContext context) {
      final name = context.attributeSignal('name');
      return Live(() => Div(
        className: 'badge',
        children: [
          Text('User: ${name.value ?? 'Guest'}'),
          Button(
            text: 'Ping',
            onClick: (e) => context.dispatchCustomEvent('badge-click', detail: {'name': name.value}),
          ),
        ],
      ));
    },
    observedAttributes: ['name'],
  );
}

Implementation

void defineCustomElement(
  String tagName,
  BloomNode Function(CustomElementContext context) builder, {
  List<String> observedAttributes = const [],
  bool useShadowDom = true,
  String shadowMode = 'open',
  @internal String? hotReloadScopeId,
}) {
  if (!RegExp(r'^[a-z][a-z0-9._-]*-[a-z0-9._-]+$').hasMatch(tagName)) {
    throw ArgumentError.value(tagName, 'tagName',
        'Custom element names must be lowercase and contain a hyphen.');
  }
  if (observedAttributes.any((name) => name.isEmpty)) {
    throw ArgumentError.value(
        observedAttributes, 'observedAttributes', 'Names must not be empty.');
  }
  final bridgeKey = '__bloom_ce_$tagName';
  final jsWindow = web.window as JSAny;
  final alreadyRegistered = web.window.customElements.get(tagName) != null;
  final previousBridge = _reflectGet(jsWindow, bridgeKey);
  if (alreadyRegistered &&
      (!isHotReloadTrackingActive() ||
          previousBridge == null ||
          !previousBridge.isA<JSObject>())) {
    return;
  }
  final bridge = previousBridge != null && previousBridge.isA<JSObject>()
      ? previousBridge as JSObject
      : _newJsObject();
  final signalScope = hotReloadScopeId == null
      ? currentBloomSignalScope
      : composeBloomSignalScope(hotReloadScopeId);
  final handlesMap = <web.HTMLElement, BloomMountHandle>{};
  final contextsMap = <web.HTMLElement, CustomElementContext>{};
  final connectedHosts = <web.HTMLElement>[];

  void onConnect(web.HTMLElement host) {
    if (handlesMap.containsKey(host)) return;
    web.ShadowRoot? shadow;
    web.Element mountTarget = host;
    if (useShadowDom) {
      shadow = host.shadowRoot;
      if (shadow == null) {
        final shadowInit = _newJsObject();
        _reflectSet(shadowInit, 'mode', shadowMode.toJS);
        shadow = host.attachShadow(shadowInit as web.ShadowRootInit);
      }
      // Reuse the wrapper across disconnect/reconnect cycles. Browsers keep a
      // custom element's shadow root after it leaves the document, and
      // attachShadow() may only be called once for a host.
      final existingWrapper = shadow
          .querySelector('div[data-bloom-shadow-root]') as web.HTMLDivElement?;
      final wrapper = existingWrapper ??
          web.document.createElement('div') as web.HTMLDivElement;
      wrapper.setAttribute('data-bloom-shadow-root', '');
      wrapper.setAttribute('style', 'display: contents;');
      if (wrapper.parentNode == null) shadow.appendChild(wrapper);
      mountTarget = wrapper;
    }
    final ctx = CustomElementContext(host: host, shadowRoot: shadow);
    contextsMap[host] = ctx;
    final node = runWithBloomSignalScopeBoundary(
      signalScope,
      () => builder(ctx),
    );
    final handle = mountToElement(
      node,
      mountTarget,
      trackHotReloadMount: false,
    );
    handlesMap[host] = handle;
    connectedHosts.add(host);
  }

  void onDisconnect(web.HTMLElement host) {
    final handle = handlesMap.remove(host);
    handle?.unmount();
    contextsMap.remove(host);
    connectedHosts.remove(host);
  }

  void onAttrChange(
      web.HTMLElement host, String name, String? oldValue, String? newValue) {
    final ctx = contextsMap[host];
    ctx?._notifyAttrChange(name, newValue);
  }

  _reflectSet(
      bridge, 'onConnect', ((web.HTMLElement host) => onConnect(host)).toJS);
  _reflectSet(bridge, 'onDisconnect',
      ((web.HTMLElement host) => onDisconnect(host)).toJS);
  _reflectSet(
    bridge,
    'onAttrChange',
    ((web.HTMLElement host, JSString name, JSAny? oldVal, JSAny? newVal) {
      onAttrChange(
        host,
        name.toDart,
        oldVal != null && oldVal.isA<JSString>()
            ? (oldVal as JSString).toDart
            : null,
        newVal != null && newVal.isA<JSString>()
            ? (newVal as JSString).toDart
            : null,
      );
    }).toJS,
  );
  if (_reflectGet(bridge, 'onHotReload') == null) {
    _reflectSet(
      bridge,
      'onHotReload',
      (() {
        final hosts = List<web.HTMLElement>.of(connectedHosts);
        final currentBridge = _reflectGet(jsWindow, bridgeKey) as JSObject?;
        for (final host in hosts) {
          onDisconnect(host);
          final connect = currentBridge == null
              ? null
              : _reflectGet(currentBridge, 'onConnect');
          if (connect != null && connect.isA<JSFunction>()) {
            _reflectApply(
                connect as JSFunction, currentBridge, [host as JSAny].toJS);
          }
        }
      }).toJS,
    );
  }
  _reflectSet(jsWindow, bridgeKey, bridge);

  if (alreadyRegistered) {
    final onHotReload = _reflectGet(bridge, 'onHotReload');
    if (onHotReload != null && onHotReload.isA<JSFunction>()) {
      _reflectApply(onHotReload as JSFunction, bridge, <JSAny?>[].toJS);
    }
    return;
  }

  final attrsJson = jsonEncode(observedAttributes);
  final tagJson = jsonEncode(tagName);
  final bridgeJson = jsonEncode(bridgeKey);

  final registerJs = '''
(function() {
  if (customElements.get($tagJson)) return;
  const bridge = window[$bridgeJson];
  class BloomElement extends HTMLElement {
    static get observedAttributes() {
      return $attrsJson;
    }
    connectedCallback() {
      if (bridge && bridge.onConnect) bridge.onConnect(this);
    }
    disconnectedCallback() {
      if (bridge && bridge.onDisconnect) bridge.onDisconnect(this);
    }
    attributeChangedCallback(name, oldValue, newValue) {
      if (bridge && bridge.onAttrChange) bridge.onAttrChange(this, name, oldValue, newValue);
    }
  }
  customElements.define($tagJson, BloomElement);
})();
''';

  final target = web.document.head ?? web.document.documentElement;
  if (target == null) {
    throw StateError('Bloom custom elements require a document element.');
  }
  final script = web.document.createElement('script') as web.HTMLScriptElement;
  if (bloomScriptNonce != null) {
    script.setAttribute('nonce', bloomScriptNonce!);
  }
  script.textContent = registerJs;
  target.appendChild(script);
  script.remove();
  if (web.window.customElements.get(tagName) == null) {
    throw StateError(
        'Bloom could not register <$tagName>. Check the script-src '
        'Content Security Policy and set bloomScriptNonce to an allowed nonce.');
  }
}