anywidget

repository·main·Indexed 21 days ago

https://github.com/manzt/anywidget

A specification and toolkit for authoring reusable, web-based widgets for interactive computing environments such as Jupyter, Google Colab, and marimo. It includes a monorepo of packages providing support for Deno, React, Vue, Svelte, Vite, and signal-based reactivity, as well as type definitions for type-safe widget development.

Tokens
36.3K
Snippets
128
Records
152
Agent score
75%

What's inside anywidget

  1. Use @anywidget/signals to bridge signal implementations with anywidget

    main

    @anywidget/signals is an anywidget front-end module (AFM) bridge that allows you to use various signal implementations (like @preact/signals-core or solid-js) to manage widget state and reactivity.

    Warning: This package is still in development and not ready for production use.

  2. What is the Anywidget Front-End Module (AFM)?

    main

    The Anywidget Front-End Module (AFM) is a specification for creating portable widget front-end code using ECMAScript modules (ESM). It allows widgets to be reused across different host platforms, such as Jupyter (via the anywidget Python library), Google Colab, VS Code, or native platforms like marimo.

    AFM focuses on two essential capabilities:

    1. Bidirectional communication with a host.
    2. DOM manipulation to modify output areas (e.g., notebook cells).
  3. What is an Anywidget Front-End Module (AFM)?

    main

    An Anywidget Front-End Module (AFM) is a specification for widget front-end code based on ECMAScript (ES) modules. It decouples widget logic from the host platform by using standard web module imports and lifecycle methods.

    An AFM is executed by a host platform (like Jupyter, marimo, or Panel) which loads the module and invokes specific lifecycle methods. This allows developers to write widgets in standard JavaScript/TypeScript or use framework bridges (like React or Svelte) to wrap complex UIs into the AFM lifecycle.

    export default {
      initialize({ model }) {
        // Add instance-specific event listeners
        return () => {
          // Clean up event listeners
        };
      },
      render({ model, el }) {
        // Render the widget
        return () => {
          // Clean up event listeners
        };
      },
    };
  4. Compose widgets using the `host` API

    main

    The host API enables widget composition, allowing one widget to render and interact with another. Using host.getWidget(id) or host.getModel(id), a parent widget can retrieve a child widget or its model to orchestrate complex UI hierarchies.

    export default {
      initialize({ model, signal }) {
        return {
          getValue: () => model.get("value"),
        };
      },
      async render({ model, el, signal, host }) {
        // Retrieve a child widget via the host API
        let child = await host.getWidget(model.get("control"));
        let div = document.createElement("div");
        el.appendChild(div);
        
        // Render the child widget into the parent's DOM element
        await child.render({ el: div, signal });
      },
    };
  5. Using ECMAScript Modules (ESM) for cross-platform JavaScript

    main

    ECMAScript Modules (ESM) provide a standardized way to package and load JavaScript code that is natively supported by all modern web browsers. By using ESM, developers can write code that runs across different notebook environments without needing to transform or bundle it for specific platform requirements. This allows for direct imports from URLs (e.g., via esm.sh), which can simplify the distribution and loading of widget front-ends.

    import * as d3 from "https://esm.sh/d3@7";
    
    export function currentDate() {
      let formatTime = d3.timeFormat("%B %d, %Y");
      console.log(`Today is ${formatTime(new Date())}`);
    }
    
    currentDate(); // Today is January 18, 2023
  6. Understand the anywidget widget lifecycle

    main

    anywidget abstracts the traditional Jupyter Widgets lifecycle into two primary hooks that you export from a JavaScript module. These hooks correspond to specific stages in the widget's lifetime:

    1. Model Initialization (initialize hook): Occurs when a matching front-end model is created and synced with the kernel. Use this for one-time setup, such as registering event handlers or creating shared state across views.
    2. View Rendering (render hook): Occurs once per output cell that displays the widget. This is where you create and append DOM elements to context.el and register event handlers to respond to state changes.

    While initialize is optional and most widgets only need render, you can export both to handle complex setup requirements.

    /** @param {{ model: DOMWidgetModel }} context */
    function initialize({ model }) {
      /* (optional) model initialization logic */
    }
    
    /** @param {{ model: DOMWidgetModel, el: HTMLElement }} context */
    function render(context) {
      let el = context.el;
      let model = context.model;
      /* view logic */
    }
    
    export default { initialize, render };
  7. Compose widgets using host.getWidget and host.getModel

    main

    A widget can render and interact with other widgets on the same page using the host object, which is available only during the render lifecycle hook.

    Widget References

    To reference another widget, use a string in the format "anywidget:<model_id>". These references can be stored in synced state as top-level traits, within lists, or inside dictionaries.

    Using the Host API

    • host.getWidget(ref): Returns a Promise that resolves once the child widget's initialize phase is complete. The resolved handle provides:
      • exports: An object containing any interface returned by the child's initialize function.
      • render({ el, signal }): A function to render the child's view into a specific DOM element. It is recommended to pass the parent's signal to the child so that aborting the parent tears down the child.
    • host.getModel(ref): Returns a Promise that resolves to the child's underlying AnyModel. Use this for direct event subscriptions or get/set/send operations without triggering a UI render.

    Important Constraints

    • No Circular References: Do not attempt to resolve widget A from B while B is being resolved from A; this will cause a deadlock.
    • Ordering: host is NOT available in initialize. This prevents parent/child initialization hazards.
    • Slot Reassignment: When a trait containing a widget reference changes, you should re-resolve the new reference and tear down the old view. A common pattern is using an AbortController to manage the lifecycle of the current child view.
    interface Host {
      getWidget<T = unknown>(
        ref: string,
      ): Promise<{ 
        exports: T; 
        render(opts: { el: HTMLElement; signal?: AbortSignal }): Promise<void>; 
      }>;
      getModel<T = unknown>(ref: string): Promise<AnyModel<T>>;
    }
  8. Manage widget lifecycle with `signal`

    main

    The signal object (an AbortSignal) is provided in the initialize and render methods. It is used to manage the lifecycle of asynchronous operations and event listeners, ensuring they are cleaned up when the widget or its view is torn down.

    Best Practices:

    • Event Listeners: Always pass { signal } to addEventListener to ensure the listener is automatically removed when the widget is destroyed.
    • Manual Cleanup: You can listen for the abort event on the signal to perform custom cleanup (e.g., signal.addEventListener("abort", () => ...)).
    • Compatibility: If you use the legacy pattern of returning a cleanup function from initialize, it is automatically wired as an abort listener on the signal.
    export default () => ({
      render({ model, el, signal }) {
        const input = document.createElement("input");
        
        // Automatically cleans up when the widget is destroyed
        input.addEventListener("input", () => {
          model.set("value", input.value);
        }, { signal });
    
        // Manual cleanup for non-standard listeners
        signal.addEventListener("abort", () => {
          console.log("Cleaning up...");
        });
    
        el.appendChild(input);
      }
    });
  9. Expose a programmatic JS interface via `initialize` exports

    main

    The initialize function (which runs once per widget instance before rendering) can optionally return an object. This object, known as exports, becomes the widget's programmatic JavaScript interface. The host stores this object and makes it available to other widgets that resolve this widget as a reference via host.getWidget.

    • If initialize returns a plain object, it is treated as the widget's exports.
    • If initialize returns a function, it is treated as a cleanup callback (legacy behavior).
    • If initialize returns nothing, it is treated as having no exports.
    export default () => ({
      initialize({ model, signal }) {
        // Returning an object makes these methods available to other widgets
        return {
          getValue: () => model.get("value"),
          setValue: (v) => {
            model.set("value", v);
            model.save_changes();
          },
          onChange: (cb) => model.on("change:value", cb),
        };
      },
      render({ model, el, signal }) {
        /* ... */
      },
    });
  10. Synchronize state between Python and JavaScript

    main

    To make a property accessible and synchronizable between the Python backend and the JavaScript frontend, use traitlets and apply the .tag(sync=True) method to the property definition.

    In JavaScript, you can interact with these synchronized properties using the model object:

    • model.get("prop_name"): Retrieves the current value.
    • model.set("prop_name", value): Updates the value locally.
    • model.save_changes(): Persists the changes to the Python backend.
    • model.on("change:prop_name", callback): Listens for changes to the property.
    # Python side
    count = traitlets.Int(0).tag(sync=True)
    // JavaScript side
    let getCount = () => model.get("count");
    model.set("count", getCount() + 1);
    model.save_changes();
    model.on("change:count", () => { /* ... */ });
  11. Expose a public interface via `initialize` exports

    main

    To allow other widgets to interact with your widget programmatically (beyond just syncing model state), you can return an object from the initialize function. This object becomes the widget's exports.

    These exports are made available to parent widgets via the host.getWidget(ref) method.

    Note on return types:

    • Returning void: No exports.
    • Returning () => void: A cleanup callback (legacy).
    • Returning object: The widget's public interface (exports).

    If you need to return a single function as your export, wrap it in an object (e.g., { call: fn }) to avoid being interpreted as a legacy cleanup callback.

    export default () => {
      let data;
      return {
        initialize({ model, signal }) {
          data = buildReactiveStore(model, { signal });
          // This object is the widget's exports
          return {
            getValue: () => data.current,
            setValue: (x) => data.set(x),
            subscribe: (cb) => data.subscribe(cb),
          };
        },
        render({ model, el, signal }) {
          // uses `data` via closure
        },
      };
    };