shadimage
nteract
libwidget-storesource: ok · refreshed

Widget Store

Pure React state management for Jupyter widget models. Handles comm_open, comm_msg, and comm_close messages with fine-grained subscriptions via useSyncExternalStore. Includes comm protocol router and buffer utilities.

npx shadcn add "https://nteract-elements.vercel.app/r/widget-store.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files6 · source cached 10/8/2026, 1:57:53 AM

registry/widgets/widget-store.ts
/**
 * Pure React widget model store for Jupyter widgets.
 *
 * This replaces the Backbone.js-based model system from @jupyter-widgets/html-manager
 * with a pure JavaScript store that integrates with React via useSyncExternalStore.
 */

// === Types ===

export interface WidgetModel {
  /** comm_id from the Jupyter protocol */
  id: string;
  /** Widget state (value, min, max, description, etc.) */
  state: Record<string, unknown>;
  /** Binary buffers for data-heavy widgets */
  buffers: ArrayBuffer[];
  /** Model class name, e.g., "IntSliderModel", "AnyModel" */
  modelName: string;
  /** Model module, e.g., "@jupyter-widgets/controls", "anywidget" */
  modelModule: string;
}

type Listener = () => void;
type KeyListener = (value: unknown) => void;
// Anywidgets expect DataView[] so they can access .buffer for the underlying ArrayBuffer
// This matches JupyterLab services which deserializes buffers as DataView[]
type CustomMessageCallback = (
  content: Record<string, unknown>,
  buffers?: DataView[],
) => void;

export interface WidgetStore {
  /** Subscribe to all model changes (for useSyncExternalStore) */
  subscribe: (listener: Listener) => () => void;
  /** Get current models snapshot (for useSyncExternalStore) */
  getSnapshot: () => Map<string, WidgetModel>;
  /** Get a single model by ID */
  getModel: (modelId: string) => WidgetModel | undefined;
  /** Create a new model (on comm_open) */
  createModel: (
    commId: string,
    state: Record<string, unknown>,
    buffers?: ArrayBuffer[],
  ) => void;
  /** Update a model's state (on comm_msg with method: "update") */
  updateModel: (
    commId: string,
    statePatch: Record<string, unknown>,
    buffers?: ArrayBuffer[],
  ) => void;
  /** Delete a model (on comm_close) */
  deleteModel: (commId: string) => void;
  /** Check if a model was explicitly closed (vs never existed) */
  wasModelClosed: (modelId: string) => boolean;
  /** Subscribe to a specific key on a specific model */
  subscribeToKey: (
    modelId: string,
    key: string,
    callback: KeyListener,
  ) => () => void;
  /** Emit a custom message to listeners for a model */
  emitCustomMessage: (
    commId: string,
    content: Record<string, unknown>,
    buffers?: ArrayBuffer[],
  ) => void;
  /** Subscribe to custom messages for a model */
  subscribeToCustomMessage: (
    commId: string,
    callback: CustomMessageCallback,
  ) => () => void;
}

// === IPY_MODEL_ Reference Resolution ===

const IPY_MODEL_PREFIX = "IPY_MODEL_";

/**
 * Check if a value is an IPY_MODEL_ reference string.
 */
export function isModelRef(value: unknown): value is string {
  return typeof value === "string" && value.startsWith(IPY_MODEL_PREFIX);
}

/**
 * Extract the model ID from an IPY_MODEL_ reference.
 */
export function parseModelRef(ref: string): string | null {
  if (ref.startsWith(IPY_MODEL_PREFIX)) {
    return ref.slice(IPY_MODEL_PREFIX.length);
  }
  return null;
}

/**
 * Resolve an IPY_MODEL_ reference to the actual model.
 * If the value is not a reference, returns it unchanged.
 */
export function resolveModelRef(
  value: unknown,
  getModel: (id: string) => WidgetModel | undefined,
): unknown {
  if (isModelRef(value)) {
    const refId = parseModelRef(value);
    return refId ? getModel(refId) : value;
  }
  return value;
}

// === Store Factory ===

/**
 * Create a new widget store instance.
 *
 * The store manages widget models and provides fine-grained subscriptions
 * for reactive updates. It's designed to work with React's useSyncExternalStore.
 */
export function createWidgetStore(): WidgetStore {
  // Internal state - using Map for O(1) lookups
  let models = new Map<string, WidgetModel>();

  // Track explicitly closed models (for distinguishing from "never existed")
  const closedModels = new Set<string>();

  // Global listeners (for useSyncExternalStore)
  const listeners = new Set<Listener>();

  // Key-specific listeners for fine-grained subscriptions
  // Structure: modelId -> key -> Set<callback>
  const keyListeners = new Map<string, Map<string, Set<KeyListener>>>();

  // Custom message listeners
  // Structure: modelId -> Set<callback>
  const customListeners = new Map<string, Set<CustomMessageCallback>>();

  // Buffered custom messages for comm_ids with no listeners yet
  // Structure: commId -> Array<{ content, buffers }>
  const customMessageBuffer = new Map<
    string,
    Array<{ content: Record<string, unknown>; buffers?: DataView[] }>
  >();
  const MAX_BUFFERED_MESSAGES = 1000;

  // Notify all global listeners that something changed
  function emitChange() {
    listeners.forEach((listener) => listener());
  }

  // Notify key-specific listeners for changed keys
  function emitKeyChanges(modelId: string, changedKeys: string[]) {
    const modelListeners = keyListeners.get(modelId);
    if (!modelListeners) return;

    const model = models.get(modelId);
    for (const key of changedKeys) {
      const keyCallbacks = modelListeners.get(key);
      if (keyCallbacks) {
        const value = model?.state[key];
        keyCallbacks.forEach((cb) => cb(value));
      }
    }
  }

  return {
    subscribe(listener: Listener): () => void {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },

    getSnapshot(): Map<string, WidgetModel> {
      return models;
    },

    getModel(modelId: string): WidgetModel | undefined {
      return models.get(modelId);
    },

    createModel(
      commId: string,
      state: Record<string, unknown>,
      buffers: ArrayBuffer[] = [],
    ): void {
      // Handle re-open: remove from closed set if re-opening
      closedModels.delete(commId);

      // Extract model metadata from state
      const modelName = (state._model_name as string) || "UnknownModel";
      const modelModule = (state._model_module as string) || "";

      const model: WidgetModel = {
        id: commId,
        state,
        buffers,
        modelName,
        modelModule,
      };

      // Create new Map to trigger useSyncExternalStore update
      models = new Map(models).set(commId, model);
      emitChange();
    },

    updateModel(
      commId: string,
      statePatch: Record<string, unknown>,
      buffers?: ArrayBuffer[],
    ): void {
      const existing = models.get(commId);
      if (!existing) {
        // Model doesn't exist yet - this can happen if messages arrive out of order
        // In practice, comm_open should always come first
        return;
      }

      // Merge state patch into existing state
      const newState = { ...existing.state, ...statePatch };
      const newModel: WidgetModel = {
        ...existing,
        state: newState,
        buffers: buffers ?? existing.buffers,
      };

      // Create new Map to trigger useSyncExternalStore update
      models = new Map(models).set(commId, newModel);

      // Emit changes
      emitChange();
      emitKeyChanges(commId, Object.keys(statePatch));
    },

    deleteModel(commId: string): void {
      if (!models.has(commId)) return;

      // Track that this model was explicitly closed
      closedModels.add(commId);

      // Create new Map without the deleted model
      models = new Map(models);
      models.delete(commId);

      // Clean up listeners and buffered messages for this model
      keyListeners.delete(commId);
      customListeners.delete(commId);
      customMessageBuffer.delete(commId);

      emitChange();
    },

    subscribeToKey(
      modelId: string,
      key: string,
      callback: KeyListener,
    ): () => void {
      // Ensure model entry exists
      if (!keyListeners.has(modelId)) {
        keyListeners.set(modelId, new Map());
      }
      const modelMap = keyListeners.get(modelId)!;

      // Ensure key entry exists
      if (!modelMap.has(key)) {
        modelMap.set(key, new Set());
      }
      modelMap.get(key)?.add(callback);

      // Return unsubscribe function
      return () => {
        modelMap.get(key)?.delete(callback);

        // Clean up empty sets
        if (modelMap.get(key)?.size === 0) {
          modelMap.delete(key);
        }
        if (modelMap.size === 0) {
          keyListeners.delete(modelId);
        }
      };
    },

    emitCustomMessage(
      commId: string,
      content: Record<string, unknown>,
      buffers?: ArrayBuffer[],
    ): void {
      // Convert ArrayBuffer[] to DataView[] for anywidget compatibility
      // Anywidgets access the underlying buffer via .buffer property
      const dataViewBuffers = buffers?.map((b) =>
        b instanceof DataView ? b : new DataView(b),
      );

      // Always buffer so future subscribers get the history (e.g. a second
      // CanvasWidget subscribing to the same CanvasManagerModel after the
      // first canvas is already mounted and receiving messages).
      if (!customMessageBuffer.has(commId)) {
        customMessageBuffer.set(commId, []);
      }
      const buffer = customMessageBuffer.get(commId)!;
      buffer.push({ content, buffers: dataViewBuffers });
      // Evict oldest messages if over limit
      if (buffer.length > MAX_BUFFERED_MESSAGES) {
        buffer.splice(0, buffer.length - MAX_BUFFERED_MESSAGES);
      }

      // Also deliver to existing subscribers
      const callbacks = customListeners.get(commId);
      if (callbacks && callbacks.size > 0) {
        callbacks.forEach((cb) => cb(content, dataViewBuffers));
      }
    },

    subscribeToCustomMessage(
      commId: string,
      callback: CustomMessageCallback,
    ): () => void {
      // Ensure entry exists
      if (!customListeners.has(commId)) {
        customListeners.set(commId, new Set());
      }
      customListeners.get(commId)?.add(callback);

      // Flush any buffered messages to this new subscriber.
      // Keep the buffer (don't delete) so other subscribers to the same
      // comm_id also receive these messages — e.g. multiple CanvasWidgets
      // subscribing to one CanvasManagerModel.
      const buffered = customMessageBuffer.get(commId);
      if (buffered && buffered.length > 0) {
        for (const msg of buffered) {
          callback(msg.content, msg.buffers);
        }
      }

      // Return unsubscribe function
      return () => {
        customListeners.get(commId)?.delete(callback);

        // Clean up empty sets
        if (customListeners.get(commId)?.size === 0) {
          customListeners.delete(commId);
        }
      };
    },

    wasModelClosed(modelId: string): boolean {
      return closedModels.has(modelId);
    },
  };
}
registry/widgets/widget-store-context.tsx
"use client";

/**
 * React context and hooks for the widget model store.
 *
 * Provides:
 * - WidgetStoreProvider: Wrap your app to enable widget support
 * - useWidgetStore: Access the store context (nullable)
 * - useWidgetModels: Subscribe to all models
 * - useWidgetModel: Subscribe to a single model
 * - useWidgetModelValue: Subscribe to a single key (finest granularity)
 */

import {
  createContext,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useSyncExternalStore,
} from "react";
import { createCanvasManagerRouter } from "./canvas-manager-subscriptions";
import { createLinkManager } from "./link-subscriptions";
import {
  type JupyterCommMessage,
  type SendMessage,
  useCommRouter,
} from "./use-comm-router";
import {
  createWidgetStore,
  resolveModelRef,
  type WidgetModel,
  type WidgetStore,
} from "./widget-store";

// === Context Types ===

interface WidgetStoreContextValue {
  store: WidgetStore;
  /** Handle incoming Jupyter comm messages */
  handleMessage: (msg: JupyterCommMessage) => void;
  /** Raw send function (prefer sendUpdate/sendCustom for specific cases) */
  sendMessage: SendMessage;
  /** Send a state update to the kernel */
  sendUpdate: (
    commId: string,
    state: Record<string, unknown>,
    buffers?: ArrayBuffer[],
  ) => void;
  /** Send a custom message to the kernel */
  sendCustom: (
    commId: string,
    content: Record<string, unknown>,
    buffers?: ArrayBuffer[],
  ) => void;
  /** Close a comm channel */
  closeComm: (commId: string) => void;
}

// === Context ===

// Export context for use in isolated iframe widget provider
export const WidgetStoreContext = createContext<WidgetStoreContextValue | null>(
  null,
);

// === Provider ===

interface WidgetStoreProviderProps {
  children: ReactNode;
  /** Function to send messages back to the kernel (for widget interactions) */
  sendMessage?: SendMessage;
}

/**
 * Provider component for the widget store.
 *
 * Wrap your app (or the part that needs widgets) with this provider.
 * Pass a sendMessage function to enable widget interactions back to the kernel.
 */
export function WidgetStoreProvider({
  children,
  sendMessage = () => {},
}: WidgetStoreProviderProps) {
  // Create store once and keep it stable across renders
  const storeRef = useRef<WidgetStore | null>(null);
  if (!storeRef.current) {
    storeRef.current = createWidgetStore();
  }
  const store = storeRef.current;

  // Use the comm router hook for message handling
  const { handleMessage, sendUpdate, sendCustom, closeComm } = useCommRouter({
    sendMessage,
    store,
  });

  // Manage link subscriptions (jslink/jsdlink) at the store level.
  // Headless widgets like LinkModel have _view_name: null and won't be
  // in any container's children, so they need store-level subscriptions.
  // Pass sendUpdate so linked values sync back to the Python kernel.
  useEffect(() => createLinkManager(store, sendUpdate), [store, sendUpdate]);
  useEffect(() => createCanvasManagerRouter(store), [store]);

  const value = useMemo(
    () => ({
      store,
      handleMessage,
      sendMessage,
      sendUpdate,
      sendCustom,
      closeComm,
    }),
    [store, handleMessage, sendMessage, sendUpdate, sendCustom, closeComm],
  );

  return (
    <WidgetStoreContext.Provider value={value}>
      {children}
    </WidgetStoreContext.Provider>
  );
}

// === Hooks ===

/**
 * Access the widget store context.
 * Returns null if used outside of WidgetStoreProvider.
 */
export function useWidgetStore(): WidgetStoreContextValue | null {
  return useContext(WidgetStoreContext);
}

/**
 * Access the widget store context, throwing if not available.
 * Use this when you know you're inside a WidgetStoreProvider.
 */
export function useWidgetStoreRequired(): WidgetStoreContextValue {
  const ctx = useContext(WidgetStoreContext);
  if (!ctx) {
    throw new Error(
      "useWidgetStoreRequired must be used within WidgetStoreProvider",
    );
  }
  return ctx;
}

/**
 * Subscribe to all widget models.
 * Re-renders when any model is added, updated, or removed.
 */
export function useWidgetModels(): Map<string, WidgetModel> {
  const { store } = useWidgetStoreRequired();

  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getSnapshot, // SSR snapshot (same as client)
  );
}

/**
 * Subscribe to a specific widget model.
 * Re-renders when that model's state changes.
 */
export function useWidgetModel(modelId: string): WidgetModel | undefined {
  const { store } = useWidgetStoreRequired();

  const subscribe = useCallback(
    (callback: () => void) => store.subscribe(callback),
    [store],
  );

  const getSnapshot = useCallback(
    () => store.getModel(modelId),
    [store, modelId],
  );

  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

/**
 * Subscribe to a specific key in a widget model's state.
 * This is the finest granularity - only re-renders when that specific key changes.
 *
 * @example
 * const value = useWidgetModelValue<number>(modelId, 'value');
 * const description = useWidgetModelValue<string>(modelId, 'description');
 */
export function useWidgetModelValue<T = unknown>(
  modelId: string,
  key: string,
): T | undefined {
  const { store } = useWidgetStoreRequired();

  const subscribe = useCallback(
    (callback: () => void) => store.subscribeToKey(modelId, key, callback),
    [store, modelId, key],
  );

  const getSnapshot = useCallback(
    () => store.getModel(modelId)?.state[key] as T | undefined,
    [store, modelId, key],
  );

  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

/**
 * Get a value from a widget model, resolving IPY_MODEL_ references.
 * If the value is an IPY_MODEL_<id> reference, returns the referenced model.
 *
 * @example
 * // If state.layout is "IPY_MODEL_abc123", this returns the LayoutModel
 * const layout = useResolvedModelValue(modelId, 'layout');
 */
export function useResolvedModelValue<T = unknown>(
  modelId: string,
  key: string,
): T | WidgetModel | undefined {
  const { store } = useWidgetStoreRequired();
  const value = useWidgetModelValue(modelId, key);

  // Resolve IPY_MODEL_ reference if applicable
  const resolved = resolveModelRef(value, (id) => store.getModel(id));

  return resolved as T | WidgetModel | undefined;
}

/**
 * Check if a widget model was explicitly closed (e.g., tqdm with leave=False).
 * Returns true if the model was closed, false if it never existed or is active.
 *
 * Note: This hook is not reactive on its own. It relies on being used alongside
 * useWidgetModel, which subscribes to store changes and triggers re-renders.
 */
export function useWasWidgetClosed(modelId: string): boolean {
  const { store } = useWidgetStoreRequired();
  return store.wasModelClosed(modelId);
}

// Re-export store-level managers for non-React integrations (e.g. iframe isolation)
export { createCanvasManagerRouter } from "./canvas-manager-subscriptions";
export { createLinkManager } from "./link-subscriptions";
export type {
  JupyterCommMessage,
  JupyterMessageHeader,
  SendMessage,
} from "./use-comm-router";
// Re-export types from use-comm-router
export { useCommRouter } from "./use-comm-router";
export type { WidgetModel, WidgetStore } from "./widget-store";
// Re-export types and utilities from widget-store
export { isModelRef, parseModelRef, resolveModelRef } from "./widget-store";
registry/widgets/use-comm-router.ts
/**
 * Comm Protocol Router Hook
 *
 * Handles routing of Jupyter comm messages between the kernel and widget store.
 * Provides both inbound message handling and outbound message construction.
 *
 * Message format follows the Jupyter messaging protocol specification and is
 * compatible with strongly-typed backends (Rust, Go) that require explicit fields.
 *
 * @see https://jupyter-widgets.readthedocs.io/en/latest/examples/Widget%20Low%20Level.html
 * @see https://jupyter-client.readthedocs.io/en/latest/messaging.html
 */

import { useCallback, useEffect, useRef } from "react";
import { applyBufferPaths } from "./buffer-utils";
import type { WidgetStore } from "./widget-store";

// === Message Types ===

/**
 * Jupyter message header.
 * Required fields vary between incoming (may have fewer) and outgoing (should have all).
 */
export interface JupyterMessageHeader {
  msg_id: string;
  msg_type: string;
  username?: string;
  session?: string;
  date?: string;
  version?: string;
}

/**
 * Full header with all fields required (for outgoing messages).
 */
interface FullJupyterMessageHeader {
  msg_id: string;
  msg_type: string;
  username: string;
  session: string;
  date: string;
  version: string;
}

/**
 * Jupyter comm message.
 * Some fields are optional for incoming messages but should be set for outgoing.
 */
export interface JupyterCommMessage {
  header: JupyterMessageHeader;
  /** Should be null for outgoing messages (not undefined or empty object) */
  parent_header?: JupyterMessageHeader | null;
  metadata?: Record<string, unknown>;
  content: {
    comm_id?: string;
    target_name?: string;
    data?: {
      state?: Record<string, unknown>;
      method?: string;
      content?: Record<string, unknown>;
      buffer_paths?: string[][];
      [key: string]: unknown;
    };
  };
  buffers?: ArrayBuffer[];
  channel?: string | null;
}

/**
 * Outgoing message with all fields populated for protocol compliance.
 * Use this type for messages being sent to strongly-typed backends.
 */
interface OutgoingJupyterCommMessage {
  header: FullJupyterMessageHeader;
  parent_header: null;
  metadata: Record<string, unknown>;
  content: {
    comm_id: string;
    data?: {
      state?: Record<string, unknown>;
      method?: string;
      content?: Record<string, unknown>;
      buffer_paths: string[][];
    };
  };
  buffers: ArrayBuffer[];
  channel: string;
}

/**
 * Function type for sending messages to the kernel.
 */
export type SendMessage = (msg: JupyterCommMessage) => void;

// === Hook Types ===

export interface UseCommRouterOptions {
  /** Function to send messages to the kernel */
  sendMessage: SendMessage;
  /** Widget store instance */
  store: WidgetStore;
  /** Optional username for message headers (default: "frontend") */
  username?: string;
}

export interface UseCommRouterReturn {
  /** Handle incoming Jupyter comm messages */
  handleMessage: (msg: JupyterCommMessage) => void;
  /** Send a state update to the kernel */
  sendUpdate: (
    commId: string,
    state: Record<string, unknown>,
    buffers?: ArrayBuffer[],
  ) => void;
  /** Send a custom message to the kernel */
  sendCustom: (
    commId: string,
    content: Record<string, unknown>,
    buffers?: ArrayBuffer[],
  ) => void;
  /** Close a comm channel */
  closeComm: (commId: string) => void;
}

// === Message Construction Helpers ===

// Session ID for this frontend instance (stable across messages)
const SESSION_ID = crypto.randomUUID();

/**
 * Create a complete Jupyter message header with all fields.
 * All fields are required for compatibility with strongly-typed backends.
 */
function createHeader(
  msgType: string,
  username: string,
): FullJupyterMessageHeader {
  return {
    msg_id: crypto.randomUUID(),
    msg_type: msgType,
    username,
    session: SESSION_ID,
    date: new Date().toISOString(),
    version: "5.3",
  };
}

/**
 * Create a comm_msg for state updates.
 * Includes all required fields for Jupyter protocol compliance.
 */
function createUpdateMessage(
  commId: string,
  state: Record<string, unknown>,
  buffers: ArrayBuffer[] | undefined,
  username: string,
): OutgoingJupyterCommMessage {
  return {
    header: createHeader("comm_msg", username),
    parent_header: null,
    metadata: {},
    content: {
      comm_id: commId,
      data: {
        method: "update",
        state,
        buffer_paths: [],
      },
    },
    buffers: buffers ?? [],
    channel: "shell",
  };
}

/**
 * Create a comm_msg for custom messages.
 * Includes all required fields for Jupyter protocol compliance.
 */
function createCustomMessage(
  commId: string,
  content: Record<string, unknown>,
  buffers: ArrayBuffer[] | undefined,
  username: string,
): OutgoingJupyterCommMessage {
  return {
    header: createHeader("comm_msg", username),
    parent_header: null,
    metadata: {},
    content: {
      comm_id: commId,
      data: {
        method: "custom",
        content,
        buffer_paths: [],
      },
    },
    buffers: buffers ?? [],
    channel: "shell",
  };
}

/**
 * Create a comm_close message.
 * Includes all required fields for Jupyter protocol compliance.
 */
function createCloseMessage(
  commId: string,
  username: string,
): OutgoingJupyterCommMessage {
  return {
    header: createHeader("comm_close", username),
    parent_header: null,
    metadata: {},
    content: {
      comm_id: commId,
    },
    buffers: [],
    channel: "shell",
  };
}

// === Hook Implementation ===

/**
 * Hook for routing Jupyter comm protocol messages.
 *
 * Handles:
 * - Inbound: comm_open, comm_msg (update/custom), comm_close
 * - Outbound: sendUpdate, sendCustom, closeComm
 *
 * @example
 * const { handleMessage, sendUpdate, sendCustom, closeComm } = useCommRouter({
 *   sendMessage: (msg) => kernel.send(msg),
 *   store: widgetStore,
 * });
 *
 * // Route incoming messages
 * kernel.onMessage((msg) => handleMessage(msg));
 *
 * // Send updates back to kernel
 * sendUpdate(commId, { value: 42 });
 */
export function useCommRouter({
  sendMessage,
  store,
  username = "frontend",
}: UseCommRouterOptions): UseCommRouterReturn {
  // Refs for values that need to be fresh but shouldn't cause function identity changes.
  // This ensures sendUpdate, sendCustom, etc. are stable across renders, preventing
  // unnecessary effect re-runs in consumer widgets (see PR #137 pattern).
  const sendMessageRef = useRef(sendMessage);
  const storeRef = useRef(store);
  const usernameRef = useRef(username);

  // Keep refs up-to-date without changing function identities
  useEffect(() => {
    sendMessageRef.current = sendMessage;
    storeRef.current = store;
    usernameRef.current = username;
  });

  /**
   * Handle incoming Jupyter comm messages.
   * Routes to appropriate store methods based on message type.
   */
  const handleMessage = useCallback((msg: JupyterCommMessage) => {
    const msgType = msg.header.msg_type;
    const commId = msg.content.comm_id;

    if (!commId) return;

    switch (msgType) {
      case "comm_open": {
        // Get state and apply buffer paths if present
        let state = msg.content.data?.state || {};
        const bufferPaths = msg.content.data?.buffer_paths;

        if (bufferPaths && msg.buffers?.length) {
          state = { ...state };
          applyBufferPaths(state, bufferPaths, msg.buffers);
        }

        storeRef.current.createModel(commId, state, msg.buffers);
        break;
      }

      case "comm_msg": {
        const data = msg.content.data;
        const method = data?.method;

        if (method === "update" && data?.state) {
          // Apply buffer paths to state update
          let state = data.state;
          const bufferPaths = data.buffer_paths;

          if (bufferPaths && msg.buffers?.length) {
            state = { ...state };
            applyBufferPaths(state, bufferPaths, msg.buffers);
          }

          storeRef.current.updateModel(commId, state, msg.buffers);
        } else if (method === "custom") {
          // Dispatch custom message to widget handlers
          const content = (data?.content as Record<string, unknown>) || {};
          storeRef.current.emitCustomMessage(commId, content, msg.buffers);
        }
        break;
      }

      case "comm_close": {
        storeRef.current.deleteModel(commId);
        break;
      }
    }
  }, []);

  /**
   * Send a state update to the kernel.
   * Also applies optimistic update to local store for immediate UI response.
   */
  const sendUpdate = useCallback(
    (
      commId: string,
      state: Record<string, unknown>,
      buffers?: ArrayBuffer[],
    ) => {
      // Optimistic update: apply locally first for responsive UI
      storeRef.current.updateModel(commId, state, buffers);
      // Then send to kernel
      sendMessageRef.current(
        createUpdateMessage(commId, state, buffers, usernameRef.current),
      );
    },
    [],
  );

  /**
   * Send a custom message to the kernel.
   */
  const sendCustom = useCallback(
    (
      commId: string,
      content: Record<string, unknown>,
      buffers?: ArrayBuffer[],
    ) => {
      sendMessageRef.current(
        createCustomMessage(commId, content, buffers, usernameRef.current),
      );
    },
    [],
  );

  /**
   * Close a comm channel.
   * Sends comm_close to kernel and removes model from store.
   */
  const closeComm = useCallback((commId: string) => {
    sendMessageRef.current(createCloseMessage(commId, usernameRef.current));
    storeRef.current.deleteModel(commId);
  }, []);

  return {
    handleMessage,
    sendUpdate,
    sendCustom,
    closeComm,
  };
}
registry/widgets/buffer-utils.ts
/**
 * Buffer path utilities for Jupyter widget protocol.
 *
 * Handles the buffer_paths field in comm messages which specifies
 * where binary buffers should be inserted into the JSON message data.
 *
 * @see https://jupyter-widgets.readthedocs.io/en/latest/examples/Widget%20Low%20Level.html
 */

/**
 * Apply buffers to message data at specified paths.
 *
 * The Jupyter widget protocol sends binary data separately from JSON.
 * The buffer_paths field specifies where each buffer should be inserted.
 *
 * @example
 * // buffer_paths: [["state", "data"], ["content", "blob"]]
 * // buffers: [ArrayBuffer1, ArrayBuffer2]
 * // data: { state: {}, content: {} }
 * // Result: { state: { data: ArrayBuffer1 }, content: { blob: ArrayBuffer2 } }
 *
 * @param data - The message data object to modify (mutated in place)
 * @param bufferPaths - Array of paths, each path is an array of keys
 * @param buffers - Array of buffers to insert at the corresponding paths
 * @returns The modified data object (same reference as input)
 */
export function applyBufferPaths(
  data: Record<string, unknown>,
  bufferPaths: string[][] | undefined,
  buffers: ArrayBuffer[] | undefined,
): Record<string, unknown> {
  if (!bufferPaths || !buffers || bufferPaths.length === 0) {
    return data;
  }

  for (let i = 0; i < bufferPaths.length && i < buffers.length; i++) {
    const path = bufferPaths[i];
    const buffer = buffers[i];

    if (path.length === 0) continue;

    // Navigate to the parent object
    let current: Record<string, unknown> = data;
    for (let j = 0; j < path.length - 1; j++) {
      const key = path[j];
      if (current[key] === undefined || current[key] === null) {
        current[key] = {};
      }
      current = current[key] as Record<string, unknown>;
    }

    // Set the buffer at the final key
    const finalKey = path[path.length - 1];
    current[finalKey] = buffer;
  }

  return data;
}

/**
 * Extract buffers from message data at specified paths.
 *
 * This is the reverse of applyBufferPaths - used when sending
 * messages from the frontend to the kernel.
 *
 * @param data - The message data object (mutated in place, buffer values set to null)
 * @param bufferPaths - Array of paths where buffers should be extracted from
 * @returns Array of extracted buffers (or undefined for paths that don't exist)
 */
export function extractBuffers(
  data: Record<string, unknown>,
  bufferPaths: string[][] | undefined,
): ArrayBuffer[] {
  if (!bufferPaths || bufferPaths.length === 0) {
    return [];
  }

  const buffers: ArrayBuffer[] = [];

  for (const path of bufferPaths) {
    if (path.length === 0) {
      buffers.push(new ArrayBuffer(0));
      continue;
    }

    // Navigate to the parent object
    let current: Record<string, unknown> = data;
    let found = true;

    for (let j = 0; j < path.length - 1; j++) {
      const key = path[j];
      if (
        current[key] === undefined ||
        current[key] === null ||
        typeof current[key] !== "object"
      ) {
        found = false;
        break;
      }
      current = current[key] as Record<string, unknown>;
    }

    if (found) {
      const finalKey = path[path.length - 1];
      const value = current[finalKey];

      if (value instanceof ArrayBuffer) {
        buffers.push(value);
        // Replace with null in the data (per protocol spec)
        current[finalKey] = null;
      } else if (
        ArrayBuffer.isView(value) &&
        value.buffer instanceof ArrayBuffer
      ) {
        // Handle typed arrays (Uint8Array, etc.) with ArrayBuffer backing
        buffers.push(
          value.buffer.slice(
            value.byteOffset,
            value.byteOffset + value.byteLength,
          ),
        );
        current[finalKey] = null;
      } else {
        // Path exists but value is not a buffer
        buffers.push(new ArrayBuffer(0));
      }
    } else {
      // Path not found
      buffers.push(new ArrayBuffer(0));
    }
  }

  return buffers;
}

/**
 * Convert an ArrayBuffer or Uint8Array to a base64 string.
 */
export function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
  const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
  let binary = "";
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

/**
 * Build a media src URL from a widget value that may be a string or binary data.
 *
 * Handles all value types sent by the Jupyter widget protocol:
 * - Binary data (ArrayBuffer/Uint8Array) from from_url() or from_file()
 * - Data URLs, HTTP URLs, or absolute paths (passed through)
 * - Plain base64 strings (wrapped in a data URL)
 * - Falsy values (returns undefined)
 */
export function buildMediaSrc(
  value: string | ArrayBuffer | Uint8Array | null | undefined,
  mediaType: string,
  format: string,
): string | undefined {
  if (!value) return undefined;

  if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
    const base64 = arrayBufferToBase64(value);
    return `data:${mediaType}/${format};base64,${base64}`;
  }

  if (typeof value === "string") {
    if (
      value.startsWith("data:") ||
      value.startsWith("http://") ||
      value.startsWith("https://") ||
      value.startsWith("/")
    ) {
      return value;
    }
    return `data:${mediaType}/${format};base64,${value}`;
  }

  return undefined;
}

/**
 * Find all ArrayBuffer values in an object and return their paths.
 *
 * Useful for automatically detecting buffer paths when sending data.
 *
 * @param data - The data object to scan
 * @param prefix - Current path prefix (for recursion)
 * @returns Array of paths to ArrayBuffer values
 */
export function findBufferPaths(
  data: Record<string, unknown>,
  prefix: string[] = [],
): string[][] {
  const paths: string[][] = [];

  for (const [key, value] of Object.entries(data)) {
    const currentPath = [...prefix, key];

    if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
      paths.push(currentPath);
    } else if (
      value !== null &&
      typeof value === "object" &&
      !Array.isArray(value)
    ) {
      // Recurse into nested objects
      paths.push(
        ...findBufferPaths(value as Record<string, unknown>, currentPath),
      );
    }
  }

  return paths;
}
registry/widgets/link-subscriptions.ts
/**
 * Store-level link subscriptions for frontend-only property synchronization.
 *
 * Manages LinkModel (jslink) and DirectionalLinkModel (jsdlink) at the
 * store level so links work without React component mounting. This is
 * critical because link widgets are headless (_view_name: null) and won't
 * appear in any container widget's children.
 *
 * Usage:
 *   const cleanup = createLinkManager(store);
 *   // ... later, to tear down all links:
 *   cleanup();
 *
 * WidgetStoreProvider calls this automatically. For non-React integrations
 * (e.g. iframe isolation), call createLinkManager directly.
 */

import { parseModelRef, type WidgetStore } from "./widget-store";

/**
 * Parse a link source/target tuple from widget state.
 * The state arrives as: ["IPY_MODEL_<id>", "attribute_name"]
 * Returns [modelId, attrName] or null if malformed.
 */
function parseLinkTarget(tuple: unknown): [string, string] | null {
  if (
    !Array.isArray(tuple) ||
    tuple.length !== 2 ||
    typeof tuple[0] !== "string" ||
    typeof tuple[1] !== "string"
  ) {
    return null;
  }
  const modelId = parseModelRef(tuple[0]);
  if (!modelId) return null;
  return [modelId, tuple[1]];
}

/** Type for the sendUpdate function that syncs state to the kernel */
type SendUpdate = (commId: string, state: Record<string, unknown>) => void;

/**
 * Set up a one-way property subscription (source → target).
 * Returns a cleanup function to tear down the subscription.
 */
function setupDirectionalLink(
  store: WidgetStore,
  linkModelId: string,
  sendUpdate: SendUpdate,
): () => void {
  let keyUnsub: (() => void) | undefined;
  let globalUnsub: (() => void) | undefined;
  let isSetUp = false;

  function trySetup() {
    if (isSetUp) return;

    const linkModel = store.getModel(linkModelId);
    if (!linkModel) return;

    const src = parseLinkTarget(linkModel.state.source);
    const tgt = parseLinkTarget(linkModel.state.target);
    if (!src || !tgt) return;

    const [sourceModelId, sourceAttr] = src;
    const [targetModelId, targetAttr] = tgt;

    if (!store.getModel(sourceModelId) || !store.getModel(targetModelId)) {
      return;
    }
    isSetUp = true;

    // Initial sync: read source value, write to target
    const sourceModel = store.getModel(sourceModelId);
    if (sourceModel) {
      const currentValue = sourceModel.state[sourceAttr];
      if (currentValue !== undefined) {
        sendUpdate(targetModelId, { [targetAttr]: currentValue });
      }
    }

    // Subscribe to source changes, propagate to target
    keyUnsub = store.subscribeToKey(sourceModelId, sourceAttr, (newValue) => {
      sendUpdate(targetModelId, { [targetAttr]: newValue });
    });

    // Clean up global listener once setup is complete
    if (globalUnsub) {
      globalUnsub();
      globalUnsub = undefined;
    }
  }

  trySetup();

  // If source/target models aren't ready yet, wait for them
  if (!isSetUp) {
    globalUnsub = store.subscribe(() => trySetup());
  }

  return () => {
    globalUnsub?.();
    keyUnsub?.();
  };
}

/**
 * Set up a bidirectional property subscription (source ↔ target).
 * Uses a synchronous guard to prevent infinite loops since store
 * subscriptions fire synchronously during updateModel.
 * Returns a cleanup function to tear down the subscriptions.
 */
function setupBidirectionalLink(
  store: WidgetStore,
  linkModelId: string,
  sendUpdate: SendUpdate,
): () => void {
  const keyUnsubs: (() => void)[] = [];
  let globalUnsub: (() => void) | undefined;
  let isSetUp = false;
  let isSyncing = false;

  function trySetup() {
    if (isSetUp) return;

    const linkModel = store.getModel(linkModelId);
    if (!linkModel) return;

    const src = parseLinkTarget(linkModel.state.source);
    const tgt = parseLinkTarget(linkModel.state.target);
    if (!src || !tgt) return;

    const [sourceModelId, sourceAttr] = src;
    const [targetModelId, targetAttr] = tgt;

    if (!store.getModel(sourceModelId) || !store.getModel(targetModelId)) {
      return;
    }
    isSetUp = true;

    // Initial sync: source → target
    const sourceModel = store.getModel(sourceModelId);
    if (sourceModel) {
      const currentValue = sourceModel.state[sourceAttr];
      if (currentValue !== undefined) {
        isSyncing = true;
        sendUpdate(targetModelId, { [targetAttr]: currentValue });
        isSyncing = false;
      }
    }

    // Source → Target
    keyUnsubs.push(
      store.subscribeToKey(sourceModelId, sourceAttr, (newValue) => {
        if (isSyncing) return;
        isSyncing = true;
        sendUpdate(targetModelId, { [targetAttr]: newValue });
        isSyncing = false;
      }),
    );

    // Target → Source
    keyUnsubs.push(
      store.subscribeToKey(targetModelId, targetAttr, (newValue) => {
        if (isSyncing) return;
        isSyncing = true;
        sendUpdate(sourceModelId, { [sourceAttr]: newValue });
        isSyncing = false;
      }),
    );

    // Clean up global listener once setup is complete
    if (globalUnsub) {
      globalUnsub();
      globalUnsub = undefined;
    }
  }

  trySetup();

  // If source/target models aren't ready yet, wait for them
  if (!isSetUp) {
    globalUnsub = store.subscribe(() => trySetup());
  }

  return () => {
    globalUnsub?.();
    keyUnsubs.forEach((unsub) => unsub());
  };
}

/**
 * Create a link manager that monitors the store for LinkModel and
 * DirectionalLinkModel widgets and manages their property subscriptions.
 *
 * Returns a cleanup function that tears down all active link subscriptions.
 *
 * Called automatically by WidgetStoreProvider. For non-React integrations
 * (e.g. iframe isolation), call this directly after creating the store.
 *
 * @param store - The widget store instance
 * @param sendUpdate - Function to send state updates to the kernel. This ensures
 *   linked widget values are synced back to Python, not just updated in the frontend.
 */
export function createLinkManager(
  store: WidgetStore,
  sendUpdate: SendUpdate,
): () => void {
  const activeLinks = new Map<string, () => void>();
  let lastSize = -1;

  function scan() {
    const models = store.getSnapshot();

    // Only do a full scan when models are added or removed.
    // State updates (e.g. slider drags) don't change the map size.
    if (models.size === lastSize) return;
    lastSize = models.size;

    // Set up new links
    models.forEach((model, id) => {
      if (activeLinks.has(id)) return;

      if (model.modelName === "DirectionalLinkModel") {
        activeLinks.set(id, setupDirectionalLink(store, id, sendUpdate));
      } else if (model.modelName === "LinkModel") {
        activeLinks.set(id, setupBidirectionalLink(store, id, sendUpdate));
      }
    });

    // Clean up removed links
    for (const [id, cleanup] of activeLinks) {
      if (!models.has(id)) {
        cleanup();
        activeLinks.delete(id);
      }
    }
  }

  const unsubscribe = store.subscribe(scan);
  scan();

  return () => {
    unsubscribe();
    activeLinks.forEach((cleanup) => cleanup());
    activeLinks.clear();
  };
}
registry/widgets/canvas-manager-subscriptions.ts
/**
 * Store-level routing for ipycanvas CanvasManagerModel.
 *
 * CanvasManagerModel is a headless widget (_view_name: null) that receives
 * ALL drawing commands from Python. This module subscribes to each manager's
 * custom messages, parses switchCanvas targets, and re-emits to each target
 * canvas's comm_id — isolating canvases from each other.
 *
 * Same pattern as link-subscriptions.ts for LinkModel/DirectionalLinkModel.
 *
 * Usage:
 *   const cleanup = createCanvasManagerRouter(store);
 *   // ... later, to tear down all routing:
 *   cleanup();
 *
 * WidgetStoreProvider calls this automatically. For non-React integrations
 * (e.g. iframe isolation), call createCanvasManagerRouter directly.
 */

import type { WidgetStore } from "./widget-store";

// ipycanvas drawing command names indexed by protocol number.
// Duplicated from ipycanvas-commands.ts to keep the router self-contained
// within the widget-store package. Must match the Python-side enum in ipycanvas.
const COMMANDS = [
  "fillRect",
  "strokeRect",
  "fillRects",
  "strokeRects",
  "clearRect",
  "fillArc",
  "fillCircle",
  "strokeArc",
  "strokeCircle",
  "fillArcs",
  "strokeArcs",
  "fillCircles",
  "strokeCircles",
  "strokeLine",
  "beginPath",
  "closePath",
  "stroke",
  "strokePath",
  "fillPath",
  "fill",
  "moveTo",
  "lineTo",
  "rect",
  "arc",
  "ellipse",
  "arcTo",
  "quadraticCurveTo",
  "bezierCurveTo",
  "fillText",
  "strokeText",
  "setLineDash",
  "drawImage",
  "putImageData",
  "clip",
  "save",
  "restore",
  "translate",
  "rotate",
  "scale",
  "transform",
  "setTransform",
  "resetTransform",
  "set",
  "clear",
  "sleep",
  "fillPolygon",
  "strokePolygon",
  "strokeLines",
  "fillPolygons",
  "strokePolygons",
  "strokeLineSegments",
  "fillStyledRects",
  "strokeStyledRects",
  "fillStyledCircles",
  "strokeStyledCircles",
  "fillStyledArcs",
  "strokeStyledArcs",
  "fillStyledPolygons",
  "strokeStyledPolygons",
  "strokeStyledLineSegments",
  "switchCanvas",
] as const;

/**
 * Convert a DataView to a TypedArray based on dtype metadata.
 * Inlined here to avoid depending on ipycanvas-commands.ts,
 * keeping the router self-contained within the widget-store package.
 */
function getTypedArray(
  dataview: DataView,
  metadata: { dtype: string },
): ArrayBufferView {
  const buffer = dataview.buffer;
  switch (metadata.dtype) {
    case "int8":
      return new Int8Array(buffer);
    case "uint8":
      return new Uint8Array(buffer);
    case "int16":
      return new Int16Array(buffer);
    case "uint16":
      return new Uint16Array(buffer);
    case "int32":
      return new Int32Array(buffer);
    case "uint32":
      return new Uint32Array(buffer);
    case "float32":
      return new Float32Array(buffer);
    case "float64":
      return new Float64Array(buffer);
    default:
      return new Uint8Array(buffer);
  }
}

/**
 * Walk a command structure and collect switchCanvas target IDs.
 * Calls setTarget as a side effect so subsequent messages without
 * switchCanvas route to the last known target.
 */
function collectSwitchCanvasTargets(
  commands: unknown,
  targets: Set<string>,
  setTarget: (id: string) => void,
): void {
  if (!Array.isArray(commands) || commands.length === 0) return;

  if (Array.isArray(commands[0])) {
    for (const sub of commands) {
      collectSwitchCanvasTargets(sub, targets, setTarget);
    }
  } else {
    const cmdIndex = commands[0] as number;
    if (COMMANDS[cmdIndex] === "switchCanvas") {
      const args = commands[1] as string[] | undefined;
      const ref = args?.[0] ?? "";
      const targetId = ref.startsWith("IPY_MODEL_") ? ref.slice(10) : ref;
      setTarget(targetId);
      targets.add(targetId);
    }
  }
}

/**
 * Set up message routing for a single CanvasManagerModel.
 * Returns a cleanup function to tear down the subscription.
 */
function setupManagerRouting(
  store: WidgetStore,
  managerId: string,
): () => void {
  let currentTarget: string | null = null;

  return store.subscribeToCustomMessage(managerId, (content, buffers) => {
    if (!buffers || buffers.length === 0) return;

    try {
      const metadata = content as { dtype: string };
      const typedArray = getTypedArray(buffers[0], metadata);
      const jsonStr = new TextDecoder("utf-8").decode(typedArray);
      const commands = JSON.parse(jsonStr);

      const targets = new Set<string>();
      collectSwitchCanvasTargets(commands, targets, (id) => {
        currentTarget = id;
      });

      if (targets.size === 0 && currentTarget) {
        targets.add(currentTarget);
      }

      const rawBuffers = buffers.map((dv) => dv.buffer as ArrayBuffer);
      for (const targetId of targets) {
        store.emitCustomMessage(targetId, content, rawBuffers);
      }
    } catch (err) {
      console.warn("[ipycanvas] Manager routing error:", err);
    }
  });
}

/**
 * Create a canvas manager router that monitors the store for
 * CanvasManagerModel widgets and routes their messages to target canvases.
 *
 * Returns a cleanup function that tears down all active routing.
 *
 * Called automatically by WidgetStoreProvider. For non-React integrations
 * (e.g. iframe isolation), call this directly after creating the store.
 */
export function createCanvasManagerRouter(store: WidgetStore): () => void {
  const activeRoutes = new Map<string, () => void>();
  let lastSize = -1;

  function scan() {
    const models = store.getSnapshot();

    if (models.size === lastSize) return;
    lastSize = models.size;

    // Find CanvasModel widgets and extract their _canvas_manager reference.
    // CanvasManagerModel is a headless singleton that isn't added to the store,
    // but its ID is referenced by each CanvasModel via _canvas_manager.
    models.forEach((model, _id) => {
      if (model.modelName !== "CanvasModel") return;

      const managerRef = model.state?._canvas_manager as string | undefined;
      if (!managerRef) return;

      // Extract manager ID from "IPY_MODEL_xxx" reference
      const managerId = managerRef.startsWith("IPY_MODEL_")
        ? managerRef.slice(10)
        : managerRef;

      if (activeRoutes.has(managerId)) return;

      activeRoutes.set(managerId, setupManagerRouting(store, managerId));
    });

    // Clean up routes for managers no longer referenced by any canvas.
    const referencedManagers = new Set<string>();
    models.forEach((model) => {
      if (model.modelName === "CanvasModel") {
        const managerRef = model.state?._canvas_manager as string | undefined;
        if (managerRef) {
          const managerId = managerRef.startsWith("IPY_MODEL_")
            ? managerRef.slice(10)
            : managerRef;
          referencedManagers.add(managerId);
        }
      }
    });

    for (const [managerId, cleanup] of activeRoutes) {
      if (!referencedManagers.has(managerId)) {
        cleanup();
        activeRoutes.delete(managerId);
      }
    }
  }

  const unsubscribe = store.subscribe(scan);
  scan();

  return () => {
    unsubscribe();
    activeRoutes.forEach((cleanup) => cleanup());
    activeRoutes.clear();
  };
}

More from nteract

Browse all components in this library →