customElement function

BloomNode customElement(
  1. String tag, {
  2. Map<String, Object?>? properties,
  3. Map<String, String>? attrs,
  4. String? className,
  5. String? style,
  6. Map<String, CustomElementEventHandler>? events,
  7. Map<String, BloomEventHandler>? on,
  8. List<BloomNode> children = const [],
  9. String? text,
  10. Ref<Element>? ref,
  11. bool waitForUpgrade = true,
  12. Duration upgradeTimeout = const Duration(seconds: 3),
})

Creates a BloomNode representing a browser Custom Element / Web Component with reactive property bindings, custom event listeners, and SSR attribute support.

Seamlessly integrates third-party Web Component libraries (such as Shoelace, Lit, Fast, Material Web, Vaadin) and custom-authored elements into Bloom applications.

Properties vs Attributes Distinction

Understanding the difference between HTML attributes and JavaScript properties is essential:

  • Attributes (attrs): Plain string key-value pairs serialized directly into the HTML tag (e.g. <sl-button variant="primary" size="medium">). Attributes configure simple string or boolean states and are emitted during Server-Side Rendering (renderToHtml).
  • Properties (properties): Rich JavaScript values (objects, arrays, dates, numbers, functions) assigned directly to the live DOM element instance via JS interop after mounting. Plain HTML attributes cannot express nested structures or dynamic closures without awkward JSON stringification.

Reactive Property Updates via Signals

When a Signal, ReadonlySignal, or a getter closure () => signal.value is passed in properties, a fine-grained effect automatically tracks the signal dependency. Whenever the signal updates, the property on the live DOM element instance is updated immediately in place without re-rendering, replacing, or re-mounting the DOM node.

Custom Event Interop

The events map registers listeners for custom events emitted by the element (e.g. 'sl-change', 'ionInput', 'date-selected'). The event.detail payload is automatically deserialized into native Dart types (Map, List, primitives) and wrapped in a CustomElementEvent. Listeners maintain stable JS function references and are cleanly removed on unmount. Standard DOM events (click, input, keydown) can also be registered via on.

Why waitForUpgrade Exists

Assigning a JavaScript property to an un-upgraded custom element before its class definition is registered via customElements.define creates an "own property" directly on the HTMLElement instance. When the custom element definition subsequently loads and upgrades, the element class's prototype getter/setter is shadowed by the instance own-property, causing broken component state or lost reactivity.

When waitForUpgrade is true (the default), Bloom awaits customElements.whenDefined(tag) (up to upgradeTimeout) before applying properties, ensuring prototype setters execute correctly. If the definition never arrives within upgradeTimeout, the guard times out gracefully and assigns properties to prevent indefinite blocking.

Server-Side Rendering (SSR) Behavior

During SSR (renderToHtml), customElement emits the custom element HTML tag (<${tag} ...>) with its attrs, className, style, children, and text as static HTML for instant first paint and SEO indexing.

properties, events, on, ref, and upgrade guards are completely browser-only and run during client-side hydration and mounting (mount).

Cleanup on Unmount

All event listeners registered through events and all reactive property signal effects are automatically unregistered and disposed when the custom element is unmounted from the DOM.

final selectedTab = signal('overview');
final chartData = signal([10, 25, 45, 80]);

BloomNode dashboardChart() => customElement(
  'chart-view',
  attrs: {
    'theme': 'dark',
  },
  properties: {
    'activeTab': () => selectedTab.value,
    'series': () => chartData.value,
  },
  events: {
    'chart-select': (event) {
      print('Selected point: ${event.detail}');
    },
  },
);

Implementation

BloomNode customElement(
  String tag, {
  Map<String, Object?>? properties,
  Map<String, String>? attrs,
  String? className,
  String? style,
  Map<String, CustomElementEventHandler<dynamic>>? events,
  Map<String, BloomEventHandler>? on,
  List<BloomNode> children = const [],
  String? text,
  Ref<web.Element>? ref,
  bool waitForUpgrade = true,
  Duration upgradeTimeout = const Duration(seconds: 3),
}) {
  final elementRef = ref ?? Ref<web.Element>();
  final disposers = <void Function()>[];

  final elNode = El(
    tag,
    className: className,
    style: style,
    attrs: attrs,
    on: on,
    children: children,
    text: text,
  );

  return Mount(
    RefNode(elementRef, elNode),
    onMount: () {
      Future<void>(() async {
        if (!elementRef.isMounted) return;
        final el = elementRef.value;

        if (waitForUpgrade) {
          await whenCustomElementDefined(tag, timeout: upgradeTimeout);
          if (!elementRef.isMounted) return;
        }

        // Attach custom event listeners with stable function references
        if (events != null) {
          for (final entry in events.entries) {
            final type = entry.key;
            final handler = entry.value;

            final JSFunction jsListener = ((web.Event e) {
              final rawDetail = _reflectGet(e as JSAny, 'detail');
              final detail = jsToDartValue(rawDetail);
              final customEvent = CustomElementEvent<dynamic>(
                rawEvent: e,
                type: type,
                detail: detail,
                target: e.target as web.Element?,
              );
              handler(customEvent);
            }).toJS;

            el.addEventListener(type, jsListener);
            disposers.add(() {
              try {
                el.removeEventListener(type, jsListener);
              } catch (_) {}
            });
          }
        }

        // Apply reactive properties via effect
        if (properties != null && properties.isNotEmpty) {
          final effectDispose = effect(() {
            if (!elementRef.isMounted) return;
            for (final entry in properties.entries) {
              final propName = entry.key;
              final propValue = entry.value;
              Object? evaluated;
              if (propValue is ReadonlySignal) {
                evaluated = propValue.value;
              } else if (propValue is Object? Function()) {
                evaluated = propValue();
              } else {
                evaluated = propValue;
              }
              _reflectSet(el as JSAny, propName, dartToJsValue(evaluated));
            }
          });
          disposers.add(effectDispose);
        }
      });
    },
    onUnmount: () {
      for (final dispose in disposers) {
        try {
          dispose();
        } catch (_) {}
      }
      disposers.clear();
    },
  );
}