/* Copyright 2026 Marimo. All rights reserved. */ import { isCustomMarimoElement } from "@/plugins/core/registerReactComponent"; import { Functions } from "../../utils/functions"; import { Logger } from "../../utils/Logger"; import { UIElementId } from "../cells/ids"; import { defineCustomElement } from "./defineCustomElement"; import { MarimoValueInputEvent, type MarimoValueInputEventType, } from "./events"; import { RANDOM_ID_ATTR } from "./ui-element-constants"; import { UI_ELEMENT_REGISTRY } from "./uiregistry"; import "./ui-element.css"; const UI_ELEMENT_TAG_NAME = "MARIMO-UI-ELEMENT"; interface IUIElement extends HTMLElement { reset(): void; } /** * Lazily initialize the UIElement component. */ export function initializeUIElement() { /** * UIElement Web Component * * Synchronizes the value of its first child on page and with the kernel. * * @example * Example wrapping a custom web component: * ``` * * * * ``` * * @example * Example wrapping raw HTML: * ``` * *
* ... *
*
* ``` * * @remarks * IDENTIFICATION * UIElements are uniquely identified by their objectId, in the following * sense: UIElements with the same objectId are synchronized to have the same * value. In other words, the set of UIElements on the page can be partitioned * into equivalence classes, where two elements are equivalent if they share * the same objectId. * * Every UIElement is registered with the global UIElementRegistry. * * SYNCHRONIZATION * Using a tag declares that its first child is a component * whose value should be synchronized. Synchronization happens on two levels: * 1. multiple instances of the same component are synchronized to have the * same value * 2. a change in value on the page is sent to the kernel * * INITIAL VALUE * The first child of may optionally take a data attribute * called "initial-value" to influence its instantiation. Upon instantiation, * the UIElement component may set this attribute if it already has a value * for the objectId. * * COMMUNICATION * Communication between marimo and the child of a UIElement is facilitated * by two events: * 1. MarimoValueInputEvent: the child publishes its value by dispatching * a MarimoValueInputEvent, with `detail` set to * `{ value: }`; * 2. MarimoValueUpdateEvent: the UIElement node broadcasts a * MarimoValueUpdateEvent to every element registered under its * objectId when a new value is available; the targets of these * events (i.e., the child node of each UIElement in the equivalence * class of the objectId) are responsible for listening to these * events and updating their value internally. * */ class UIElement extends HTMLElement implements IUIElement { private initialized = false; private inputListener: (e: MarimoValueInputEventType) => void = Functions.NOOP; // This needs to happen in connectedCallback because the element may not be // set at construction time private init() { if (this.initialized) { return; } const objectId = UIElementId.parseOrThrow(this); this.inputListener = (e: MarimoValueInputEventType) => { // TODO: just fill in the objectId and let the document handle // broadcast? that would still let other elements cancel the event // while also reducing the number of event listeners on the document if (objectId !== null && e.detail.element === this.firstElementChild) { // A UIElement may be missing from the registry if it was returned from a function that caches return values. if (!UI_ELEMENT_REGISTRY.has(objectId)) { UI_ELEMENT_REGISTRY.registerInstance( objectId, child as HTMLElement, ); } UI_ELEMENT_REGISTRY.broadcastValueUpdate( child as HTMLElement, objectId, e.detail.value, ); } }; // A UIElement tracks the value of its first child. const child = this.firstElementChild; if (objectId === null) { Logger.error("[marimo-ui-element] missing object-id attribute"); return; } if (child === null) { Logger.error("[marimo-ui-element] has no child"); return; } if (!(child instanceof HTMLElement)) { Logger.error( "[marimo-ui-element] first child must be instance of HTMLElement", ); return; } this.initialized = true; } connectedCallback() { this.init(); if (this.initialized) { // It is critical that the element is registered in this method, // and not in the constructor, since it may be disconnected and // reconnected without being re-constructed const objectId = UIElementId.parseOrThrow(this); const child = this.firstElementChild as HTMLElement; UI_ELEMENT_REGISTRY.registerInstance(objectId, child); // Listen to marimo input events provided by the child element: these // events are signals to this UIElement that our value should change. document.addEventListener( MarimoValueInputEvent.TYPE, this.inputListener, ); } } disconnectedCallback() { if (this.initialized) { // Unregister everything document.removeEventListener( MarimoValueInputEvent.TYPE, this.inputListener, ); const objectId = UIElementId.parseOrThrow(this); UI_ELEMENT_REGISTRY.removeInstance( objectId, this.firstElementChild as HTMLElement, ); } } /** * Reset the value of the child element to its initial value. */ reset() { const child = this.firstElementChild; if (isCustomMarimoElement(child)) { child.reset(); } else { Logger.error( "[marimo-ui-element] first child must have a reset method", ); } } // We look for changes to the random-id attribute, which is effectively // used like a React key. If the random-id changes, we need to unmount and // remount its child. static get observedAttributes() { return [RANDOM_ID_ATTR]; } attributeChangedCallback( name: string, oldValue: string | null, newValue: string | null, ) { if (this.initialized) { const hasChanged = oldValue !== newValue; if (name === RANDOM_ID_ATTR && hasChanged) { // deregister/clean-up this instance this.disconnectedCallback(); // remove and re-add its child to force it to re-render; note that // this doesn't reconstruct the UI element, only its child const child = this.firstElementChild; if (isCustomMarimoElement(child)) { child.rerender(); } else { Logger.error( "[marimo-ui-element] first child must have a rerender method", ); } // register the element and reset its initial value this.initialized = false; this.connectedCallback(); } } } } defineCustomElement(UI_ELEMENT_TAG_NAME.toLowerCase(), UIElement); } /** * Given a node, check if its parent or itself is a UIElement, * and return its objectId if so. */ export function getUIElementObjectId(target: HTMLElement): UIElementId | null { if (!target) { return null; } if (target.nodeName === UI_ELEMENT_TAG_NAME) { return UIElementId.parseOrThrow(target); } const node = target.parentElement; if (node?.nodeName === UI_ELEMENT_TAG_NAME) { return UIElementId.parseOrThrow(node); } return null; } export function isUIElement(target: HTMLElement): target is IUIElement { return target.tagName === UI_ELEMENT_TAG_NAME; }