defineCustomElement function
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 bybuildervia 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 inobservedAttributesare 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.');
}
}