From ee1e20b82297dd2e7fb2da3a38fed82d0a207660 Mon Sep 17 00:00:00 2001 From: Kiran Gadhave Date: Thu, 25 Jun 2026 11:26:05 -0700 Subject: [PATCH] feat(mpl): reuse interactive figure manager across cell reruns Cache the FigureManagerWebAgg per figure (reference-counted, weakly held) so re-running mo.mpl.interactive(fig) reuses the manager instead of rebuilding it, preserving toolbar state and skipping the canvas teardown and mpl.js re-handshake. The frontend keeps the rendered figure mounted and rebinds the existing socket to the new comm. --- .../mpl-interactive/MplInteractivePlugin.tsx | 253 +++++++++++------- .../__tests__/MplInteractivePlugin.test.tsx | 155 ++++++++++- .../mpl-interactive/mpl-websocket-shim.ts | 10 + .../_plugins/ui/_impl/from_mpl_interactive.py | 187 +++++++++++-- .../ui/_impl/test_from_mpl_interactive.py | 169 +++++++++++- 5 files changed, 637 insertions(+), 137 deletions(-) diff --git a/frontend/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx b/frontend/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx index 6bfac55c7f7..7fd3d6461ec 100644 --- a/frontend/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +++ b/frontend/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx @@ -174,99 +174,99 @@ const MplInteractiveSlot = (props: IPluginProps) => { const containerRef = useRef(null); const figureRef = useRef(null); const wsRef = useRef(null); + // Sends to the currently bound backend model. Re-pointed on every (re)bind + // so the persistent socket and toolbar downloads always reach the live comm. + const sendRef = useRef<(msg: unknown) => void>(Functions.NOOP); + // Detaches the model bound by the most recent bindModel call. Shared between + // the mount and rebind effects so a rerun disposes the prior model's + // listener before attaching the next one (never stacking listeners). + const boundModelCleanupRef = useRef<(() => void) | undefined>(undefined); + // Latest model id, read by the mount effect without being a dependency: + // the figure is built once, and switching to a new model is the rebind + // effect's job, not a reason to tear the canvas down. + const modelIdRef = useRef(modelId); + modelIdRef.current = modelId; + // The data attributes are re-parsed into fresh objects on every rerun, so + // `toolbarImages` changes identity even when its contents do not. Read it + // from a ref so it can't retrigger the mount effect and rebuild the canvas. + const toolbarImagesRef = useRef(toolbarImages); + toolbarImagesRef.current = toolbarImages; + + // Bind the already-rendered figure/socket to a backend model, leaving the + // DOM, figure, and socket in place so they survive across reruns. Disposes + // the previously bound model first and records the new cleanup in + // boundModelCleanupRef, so exactly one model listener is ever attached. + const bindModel = useCallback(async (id: WidgetModelId): Promise => { + const fakeWs = wsRef.current; + if (!fakeWs) { + return; + } - const setupFigure = useCallback( - async (container: HTMLElement) => { - // Load mpl.js globally (only once, via