/* Copyright 2026 Marimo. All rights reserved. */ /* oxlint-disable unicorn/prefer-spread */ /** * WebComponent Factory for React Components * * Provides a factory for defining a custom web component from a React * component. The factory handles the logic of communicating UI element values * to and from the rest of marimo. */ import { Provider } from "jotai"; import React, { createRef, type JSX, type ReactNode, type SetStateAction, Suspense, useEffect, useImperativeHandle, useMemo, useState, } from "react"; import ReactDOM, { type Root } from "react-dom/client"; import useEvent from "react-use-event-hook"; import { type ZodSchema, z } from "zod"; import { TooltipProvider } from "@/components/ui/tooltip"; import { notebookAtom } from "@/core/cells/cells.ts"; import { HTMLCellId } from "@/core/cells/ids.ts"; import { isUninstantiated } from "@/core/cells/utils"; import { createInputEvent, MarimoValueUpdateEvent } from "@/core/dom/events"; import { getUIElementObjectId } from "@/core/dom/ui-element"; import { UIElementRegistry } from "@/core/dom/uiregistry"; import { FUNCTIONS_REGISTRY } from "@/core/functions/FunctionRegistry"; import { LocaleProvider } from "@/core/i18n/locale-provider"; import { store } from "@/core/state/jotai"; import { isStaticNotebook } from "@/core/static/static-state"; import { type HTMLElementNotDerivedFromRef, useEventListener, } from "@/hooks/useEventListener"; import { StyleNamespace } from "@/theme/namespace"; import { useTheme } from "@/theme/useTheme"; import { CellNotInitializedError } from "@/utils/errors.ts"; import { Functions } from "@/utils/functions"; import { shallowCompare } from "@/utils/shallow-compare"; import { defineCustomElement } from "../../core/dom/defineCustomElement"; import { parseAttrValue, parseDataset, parseInitialValue, } from "../../core/dom/htmlUtils"; import { invariant } from "../../utils/invariant"; import { Logger } from "../../utils/Logger"; import { Objects } from "../../utils/objects"; import type { IPlugin } from "../types"; import { renderError } from "./BadPlugin"; import { renderHTML } from "./RenderHTML"; import type { PluginFunctions } from "./rpc"; export interface PluginSlotHandle { /** * Reset the plugin initial value and data. */ reset: () => void; /** * Set the plugin's children. */ setChildren: (children: ReactNode) => void; } export interface IMarimoHTMLElement extends HTMLElement { /** * Reset the plugin initial value and data. */ reset: () => void; /** * Re-render the plugin. */ rerender: () => void; } interface PluginSlotProps { hostElement: HTMLElement; plugin: IPlugin; children?: ReactNode | undefined; getInitialValue: () => T; } /* Handles synchronization of value on behalf of the component */ // oxlint-disable-next-line react/function-component-definition function PluginSlotInternal( { hostElement, plugin, children, getInitialValue }: PluginSlotProps, ref: React.Ref, ): JSX.Element { const [childNodes, setChildNodes] = useState(children); const [value, setValue] = useState(getInitialValue()); const { theme } = useTheme(); const [parsedResult, setParsedResult] = useState(() => { return plugin.validator.safeParse(parseDataset(hostElement)); }); // Incremented on each reset to invalidate memoized function references. // This ensures that plugin functions (e.g., search) are re-created when // the underlying UI element instance changes (new object-id), even if // the element's data attributes haven't changed. const [resetNonce, setResetNonce] = useState(0); useImperativeHandle(ref, () => ({ reset: () => { setValue(getInitialValue()); setParsedResult(plugin.validator.safeParse(parseDataset(hostElement))); setResetNonce((n) => n + 1); }, setChildren: (children) => { setChildNodes(children); }, })); // Listen to value updates useEventListener( hostElement as HTMLElementNotDerivedFromRef, MarimoValueUpdateEvent.TYPE, (e) => { if (e.detail.element === hostElement) { setValue(e.detail.value as T); } }, ); // We create a mutation observer to listen for changes to the host element's attributes // and update the plugin's data accordingly useEffect(() => { const observer = new MutationObserver((mutations) => { const hasAttributeMutation = mutations.some( (mutation) => mutation.type === "attributes" && mutation.attributeName?.startsWith("data-"), ); if (hasAttributeMutation) { setParsedResult(plugin.validator.safeParse(parseDataset(hostElement))); } }); // Create listener observer.observe(hostElement, { attributes: true, // configure it to listen to attribute changes }); return () => { // Remove listener observer.disconnect(); }; }, [hostElement, plugin.validator]); // When the value changes, send an input event const setValueAndSendInput = useEvent((value: SetStateAction): void => { setValue((prevValue) => { const updater = Functions.asUpdater(value); const nextValue = updater(prevValue); // Shallow compare the values // If the value hasn't changed, we don't need to send an input event if (shallowCompare(nextValue, prevValue)) { return nextValue; } hostElement.dispatchEvent(createInputEvent(nextValue, hostElement)); return nextValue; }); }); // Create a map of functions that can be called by the plugin const functionMethods = useMemo(() => { if (!plugin.functions) { return {}; } const methods: PluginFunctions = {}; for (const [key, schemas] of Objects.entries(plugin.functions)) { const { input, output } = schemas as { input: ZodSchema>; output: ZodSchema>; }; methods[key] = async (...args: unknown[]) => { invariant( args.length <= 1, `Plugin functions only supports a single argument. Called ${key}`, ); const objectId = getUIElementObjectId(hostElement); invariant(objectId, "Object ID should exist"); const isStatic = isStaticNotebook(); const htmlId = HTMLCellId.findElementThroughShadowDOMs(hostElement)?.id; const cellId = htmlId ? HTMLCellId.parse(htmlId) : null; if (cellId && !isStatic) { // If the cell is not initialized, throw an error // Continue if the cell is static, so we can propagate a clearer error message. const notebookState = store.get(notebookAtom); const cellRuntime = notebookState.cellRuntime[cellId]; const cellData = notebookState.cellData[cellId]; const cellNotInitialized = isUninstantiated({ executionTime: cellRuntime.runElapsedTimeMs ?? cellData.lastExecutionTime, status: cellRuntime.status, errored: cellRuntime.errored, interrupted: cellRuntime.interrupted, stopped: cellRuntime.stopped, }); if (cellNotInitialized) { throw new CellNotInitializedError(); } } else { Logger.warn(`Cell ID ${cellId} cannot be found`); } const response = await FUNCTIONS_REGISTRY.request({ args: prettyParse(input, args[0]), functionName: key, namespace: objectId, }); if (response.status.code !== "ok") { Logger.error(response.status); throw new Error(response.status.message || "Unknown error"); } return prettyParse(output, response.return_value); }; } return methods; // oxlint-disable-next-line react-hooks/exhaustive-deps }, [plugin.functions, hostElement, resetNonce]); // If we failed to parse the initial value, render an error if (!parsedResult.success) { return renderError( parsedResult.error, Objects.mapValues(hostElement.dataset, (value) => typeof value === "string" ? parseAttrValue(value) : value, ), hostElement.shadowRoot, ); } // Render the plugin return (
}> {plugin.render({ setValue: setValueAndSendInput, value, data: parsedResult.data, children: childNodes, host: hostElement, functions: functionMethods, })}
); } const PluginSlot: React.ForwardRefExoticComponent< // oxlint-disable-next-line typescript/no-explicit-any PluginSlotProps & React.RefAttributes > = React.forwardRef(PluginSlotInternal); const styleSheetCache = new Map(); const customElementLocator = "__custom_marimo_element__"; /** * Register a React component as a custom element * * The custom element is expected to have the attribute * "data-initial-value", containing an initial value for the component. * It may take any other attributes as well. Any attributes that are prefixed * with "data-" will be passed to the component as props. * * We listen on any changes to the children of the element and re-render the * React component when they change. Instead of unmounting the component, we * replace the children via a callback passed to the component. * * We also copy any local stylesheets into the shadow root of the component. * This is necessary because the shadow root is not part of the DOM, so it * cannot access stylesheets from the main document. * We need to wait for the stylesheets to load before rendering the component, * otherwise the component my flash unstyled. * * @param plugin - The plugin to register which contains the component tagName * and the React component to render */ export function registerReactComponent(plugin: IPlugin): void { const WebComponent = class extends HTMLElement implements IMarimoHTMLElement { private observer: MutationObserver; private root?: Root; private mounted = false; private pluginRef = createRef(); protected __type__ = customElementLocator; constructor() { super(); // Create a shadow root so we can store the React tree on the shadow root, while the original // element's children are still on the DOM this.attachShadow({ mode: "open" }); this.copyStyles(); // This observer is used to detect changes to the children and re-render the component this.observer = new MutationObserver(() => { this.pluginRef.current?.setChildren(this.getChildren()); }); } connectedCallback() { // Skip mounting if this element is in the light DOM of another // marimo custom element. The parent element's shadow DOM will // re-create this element via getChildren() -> renderHTML(), so // this light DOM copy should remain inert to avoid duplicate // side-effects (e.g., mo.lazy firing load() twice). if (this.isLightDOMChildOfMarimoElement()) { return; } if (!this.mounted) { // Create a React root on the shadow root invariant(this.shadowRoot, "Shadow root should exist"); // If we already have a root, unmount it before creating a new one if (this.root) { // This can't happen in disconnectedCallback because we want React to // handle the descendants unmounting. this.root.unmount(); } this.root = ReactDOM.createRoot(this.shadowRoot); // Render the component for the first time this.mountReactComponent(); } // Listen for DOM changes this.observer.observe(this, { attributes: true, childList: true, subtree: true, characterData: true, }); } disconnectedCallback() { this.observer.disconnect(); this.root?.unmount(); if (this.mounted) { this.mounted = false; } } /** * Check if this element is in the light DOM of another marimo * custom element. When a marimo element (e.g. marimo-tabs) has * children that are also marimo elements (e.g. marimo-lazy), the * browser upgrades both the light DOM originals AND the shadow DOM * copies created by renderHTML(). The light DOM copies should not * mount since they are not rendered (no ) and would cause * duplicate side-effects. * * parentElement traversal stays within the same DOM tree boundary * (shadow root or document), so shadow DOM copies never find a * marimo ancestor and correctly return false. */ private isLightDOMChildOfMarimoElement(): boolean { let parent = this.parentElement; while (parent) { if (isCustomMarimoElement(parent)) { return true; } parent = parent.parentElement; } return false; } /** * Reset the plugin initial value and data. * And then re-render the plugin. */ reset() { this.dispatchEvent( createInputEvent(parseAttrValue(this.dataset.initialValue), this), ); this.rerender(); } /** * Re-render the plugin. */ rerender() { this.pluginRef.current?.reset(); } /** * Get the children of the element as React nodes. */ private getChildren(): React.ReactNode { // We don't sanitize the HTML here because it could be an iframe inside of tabs or accordions // If we have multiple children, we need to render each one separately if (this.children.length === 0) { return null; } if (this.children.length === 1) { return renderHTML({ html: this.innerHTML, alwaysSanitizeHtml: false }); } // Multiple children - render each one return Array.from(this.children).map((child, index) => ( {renderHTML({ html: child.outerHTML, alwaysSanitizeHtml: false })} )); } /** * Mount the React component to the shadow root */ private async mountReactComponent() { this.mounted = true; invariant(this.root, "Root must be defined"); this.root.render( { return parseInitialValue(this, UIElementRegistry.INSTANCE); }} > {this.getChildren()} , ); } /** * Copy stylesheets from the main document to the shadow root */ private async copyStyles() { const shadowRoot = this.shadowRoot; invariant(shadowRoot, "Shadow root should exist"); // If we don't support adopted stylesheets, we need to copy the styles if (!this.isAdoptedStyleSheetsSupported()) { Logger.warn( "adoptedStyleSheets not supported, copying stylesheets in a less performance way. Please consider upgrading your browser.", ); this.copyStylesFallback(); return; } // Get all stylesheets from the main document const sheets = Array.from(document.styleSheets).filter((sheet) => { // Support for styles with Vite if ( sheet.ownerNode instanceof HTMLElement && sheet.ownerNode.dataset.viteDevId ) { return true; } return shouldCopyStyleSheet(sheet); }); // Create new stylesheets if not already cached for (const sheet of sheets) { const sheetUniqueKey = sheet.href ?? (sheet.ownerNode instanceof HTMLElement ? sheet.ownerNode.dataset.viteDevId : undefined) ?? sheet.title; if (!sheetUniqueKey) { continue; } if (!styleSheetCache.has(sheetUniqueKey)) { // We need to create a new stylesheet because we can't use the same // stylesheet otherwise the browser will throw an error. const newSheet = new CSSStyleSheet(); try { newSheet.replaceSync( Array.from(sheet.cssRules) .map((rule) => { if (rule.cssText.includes("@import")) { // @import rules are not supported in adoptedStyleSheets return ""; } return rule.cssText; }) .join("\n"), ); } catch { // It is possible that the stylesheet is not accessible due to CORS // We can ignore this error } styleSheetCache.set(sheetUniqueKey, newSheet); } } // Add all stylesheets to the shadow root shadowRoot.adoptedStyleSheets = [...styleSheetCache.values()]; // Custom styles provided by the plugin if (plugin.cssStyles) { const pluginSheet = new CSSStyleSheet(); pluginSheet.replaceSync(plugin.cssStyles.join("\n")); // Add plugin styles last to take precedence shadowRoot.adoptedStyleSheets = [ ...shadowRoot.adoptedStyleSheets, pluginSheet, ]; } } private copyStylesFallback() { const shadowRoot = this.shadowRoot; invariant(shadowRoot, "Shadow root should exist"); Logger.warn( "adoptedStyleSheets not supported, copying stylesheets in a less performance way. Please consider upgrading your browser.", ); const styleSheets = Array.from(document.styleSheets).flatMap((sheet) => { if (!shouldCopyStyleSheet(sheet)) { return []; } const style = document.createElement("style"); try { style.textContent = Array.from(sheet.cssRules) .map((rule) => rule.cssText) .join("\n"); } catch { // It is possible that the stylesheet is not accessible due to CORS // We can ignore this error return []; } return [style]; }); shadowRoot.append(...styleSheets); // Custom styles provided by the plugin if (plugin.cssStyles) { const style = document.createElement("style"); style.textContent = plugin.cssStyles.join("\n"); shadowRoot.append(style); } } private isAdoptedStyleSheetsSupported() { return ( "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype ); } }; defineCustomElement(plugin.tagName, WebComponent); } // Copy the stylesheet to the shadow root if it is local // or from our assetUrl (in the case of a static notebook) // // This logic is not for security reasons, but rather to // avoid pollution of various 3rd party stylesheets function shouldCopyStyleSheet(sheet: CSSStyleSheet): boolean { // Copy local stylesheets owned by marimo if (sheet.title?.startsWith("marimo")) { return true; } if (!sheet.href) { return false; } // Must stylesheet must end with .css if (!sheet.href.endsWith(".css")) { return false; } // Copy stylesheets from localhost in development mode if ( sheet.href.includes("127.0.0.1") && (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") ) { return true; } // Copy stylesheets from our NPM package if (sheet.href.includes("/@marimo-team/")) { return true; } // Copy stylesheets served from the same origin return sheet.href.startsWith(window.location.origin); } export function isCustomMarimoElement( element: Element | null, ): element is IMarimoHTMLElement { if (!element) { return false; } if (!(element instanceof HTMLElement)) { return false; } return "__type__" in element && element.__type__ === customElementLocator; } function prettyParse(schema: z.ZodType, data: unknown): T { const result = schema.safeParse(data); if (!result.success) { Logger.log("Failed to parse data", data, result.error); throw new Error(z.prettifyError(result.error)); } return result.data; }