customElement function
- String tag, {
- Map<
String, Object?> ? properties, - Map<
String, String> ? attrs, - String? className,
- String? style,
- Map<
String, CustomElementEventHandler> ? events, - Map<
String, BloomEventHandler> ? on, - List<
BloomNode> children = const [], - String? text,
- Ref<
Element> ? ref, - bool waitForUpgrade = true,
- 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();
},
);
}