Skip to content

feat: move matplotlib interactive to use marimo comm instead of over WS#8612

Merged
mscolnick merged 8 commits intomainfrom
ms/beter-mpl
Mar 11, 2026
Merged

feat: move matplotlib interactive to use marimo comm instead of over WS#8612
mscolnick merged 8 commits intomainfrom
ms/beter-mpl

Conversation

@mscolnick
Copy link
Contributor

@mscolnick mscolnick commented Mar 7, 2026

Fixes #8452
Fixes #5934
Fixes #260

Replaces the separate uvicorn server + WebSocket proxy architecture for interactive matplotlib plots with marimo's built-in MarimoComm bidirectional communication channel.

Before: mo.mpl.interactive() spun up a background uvicorn server, rendered figures in an iframe, and proxied WebSocket connections through the marimo server. This didn't work in WASM/Pyodide (no threads, no WebSockets, no separate servers) and added significant complexity.

After: Interactive matplotlib uses the same MarimoComm/MODEL_MANAGER path as anywidget, with a fake WebSocket adapter on each side. No separate server, no iframe, no proxy endpoints.

Changes

  • New: from_mpl_interactive.py — UIElement that bridges FigureManagerWebAgg to MarimoComm
  • New: MplInteractivePlugin.tsx + mpl-websocket-shim.ts — frontend plugin using MODEL_MANAGER
  • Simplified: _mpl.py — removed ~400 lines (server manager, starlette app, WS proxy, iframe template)
  • Deleted: mpl.py endpoint, test_mpl_endpoints.py, mpl proxy fixtures from middleware tests
  • Static assets (mpl.js, CSS) served as cached virtual files instead of inlined per-element
  • Figure dimensions passed from backend to prevent resize flicker on first render

What this enables

  • Interactive matplotlib in WASM/Pyodide
  • No background threads or separate server process
  • No WebSocket proxy or iframe isolation needed

Replaces the separate uvicorn server + WebSocket proxy architecture for interactive matplotlib plots with marimo's built-in MarimoComm bidirectional communication channel.

**Before:** `mo.mpl.interactive()` spun up a background uvicorn server, rendered figures in an iframe, and proxied WebSocket connections through the marimo server. This didn't work in WASM/Pyodide (no threads, no WebSockets, no separate servers) and added significant complexity.

**After:** Interactive matplotlib uses the same MarimoComm/MODEL_MANAGER path as anywidget, with a fake WebSocket adapter on each side. No separate server, no iframe, no proxy endpoints.

### Changes

- **New:** `from_mpl_interactive.py` — UIElement that bridges `FigureManagerWebAgg` to `MarimoComm`
- **New:** `MplInteractivePlugin.tsx` + `mpl-websocket-shim.ts` — frontend plugin using MODEL_MANAGER
- **Simplified:** `_mpl.py` — removed ~400 lines (server manager, starlette app, WS proxy, iframe template)
- **Deleted:** `mpl.py` endpoint, `test_mpl_endpoints.py`, mpl proxy fixtures from middleware tests
- Static assets (mpl.js, CSS) served as cached virtual files instead of inlined per-element
- Figure dimensions passed from backend to prevent resize flicker on first render

### What this enables

- Interactive matplotlib in WASM/Pyodide
- No background threads or separate server process
- No WebSocket proxy or iframe isolation needed
Copilot AI review requested due to automatic review settings March 7, 2026 17:31
@vercel
Copy link

vercel bot commented Mar 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview, Comment Mar 10, 2026 0:22am

Request Review

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the architecture for interactive matplotlib plots from a separate uvicorn server + WebSocket proxy + iframe approach to using marimo's built-in MarimoComm bidirectional communication channel (the same path used by anywidget). This eliminates ~700 lines of server/proxy infrastructure and enables interactive matplotlib in WASM/Pyodide environments.

Changes:

  • New from_mpl_interactive.py UIElement that bridges FigureManagerWebAgg to MarimoComm, and new frontend MplInteractivePlugin.tsx + mpl-websocket-shim.ts that receives comm messages and drives mpl.js
  • Removed the entire mpl proxy endpoint (mpl.py), starlette server, WebSocket proxy, and all related tests/fixtures
  • Static assets (CSS, JS, toolbar images) served as cached virtual files instead of via the mpl server

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
marimo/_plugins/ui/_impl/from_mpl_interactive.py New UIElement bridging FigureManagerWebAgg to MarimoComm
marimo/_plugins/stateless/mpl/_mpl.py Removed ~400 lines of server/proxy code, kept shared utilities
marimo/_server/api/endpoints/mpl.py Deleted - HTTP/WS proxy endpoints no longer needed
marimo/_server/api/router.py Removed mpl_router from route registration
frontend/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx New React plugin component using MODEL_MANAGER
frontend/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts Fake WebSocket adapter translating comm messages
frontend/src/plugins/impl/mpl-interactive/__tests__/mpl-websocket-shim.test.ts Tests for the websocket shim
frontend/src/plugins/plugins.ts Registered the new MplInteractivePlugin
frontend/vite.config.mts Suppressed rollup externalization warnings
tests/_plugins/ui/_impl/test_from_mpl_interactive.py New comprehensive tests for the backend UIElement
tests/_plugins/stateless/test_mpl.py Updated tests for simplified mpl module
tests/_server/api/test_middleware.py Removed mpl proxy test fixtures and tests
tests/_server/api/endpoints/test_mpl_endpoints.py Deleted - mpl endpoint tests no longer needed
marimo/_smoke_tests/iplot.py Updated smoke test for new architecture

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const script = document.createElement("script");
script.src = jsUrl;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load mpl.js"));
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If mpl.js fails to load (e.g., network error), mplJsLoading holds the rejected promise forever and all subsequent calls to ensureMplJs will return the same rejected promise without retrying. Consider resetting mplJsLoading = null in the onerror handler so that a subsequent figure instantiation can retry the load.

Suggested change
script.onerror = () => reject(new Error("Failed to load mpl.js"));
script.onerror = () => {
mplJsLoading = null;
reject(new Error("Failed to load mpl.js"));
};

Copilot uses AI. Check for mistakes.
Comment on lines +270 to +275

setupFigure(container).then((cleanupFn) => {
cleanup = cleanupFn;
});

return () => {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setupFigure promise is not guarded against rejection. If ensureMplJs or MODEL_MANAGER.get throws, the error is silently swallowed (unhandled promise rejection). Additionally, there's a race condition: if the effect cleanup runs before setupFigure resolves, cleanup will be undefined and the model's msg:custom listener will never be removed, causing a leak.

Consider adding a .catch() for error handling and using a cancelled flag (set in the cleanup) to prevent the .then() callback from running after the component unmounts or the effect re-fires.

Suggested change
setupFigure(container).then((cleanupFn) => {
cleanup = cleanupFn;
});
return () => {
let cancelled = false;
setupFigure(container)
.then((cleanupFn) => {
if (cancelled) {
return;
}
cleanup = cleanupFn;
})
.catch((error) => {
if (cancelled) {
return;
}
Logger.error("Failed to set up MPL interactive figure", error);
});
return () => {
cancelled = true;

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +75
def _get_mpl_css() -> str:
"""Collect all matplotlib WebAgg CSS files and our custom CSS."""
from matplotlib.backends.backend_webagg_core import (
FigureManagerWebAgg,
)

static_path = Path(
FigureManagerWebAgg.get_static_file_path() # type: ignore[no-untyped-call]
)
parts: list[str] = []
for css_file in sorted(static_path.glob("css/*.css")):
parts.append(css_file.read_text())
# Our own overrides
parts.append(css_content)
return "\n".join(parts)
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The custom CSS contains rules targeting body (background-color: transparent; width: 100%) and #figure (line 126). Previously these were scoped within an iframe, but now the CSS is injected directly into the page as a <link> tag. The body rules will leak and affect the entire page.

Additionally, _get_mpl_css() also collects matplotlib's own CSS files (like page.css, boilerplate.css) which may contain similarly broad selectors. These should either be scoped to a container class/element, or be loaded inside a Shadow DOM to prevent CSS leaking.

Copilot uses AI. Check for mistakes.
@mscolnick mscolnick added the enhancement New feature or request label Mar 7, 2026
- Reset mplJsLoading on script load failure so retries work
- Guard setupFigure promise with cancelled flag and .catch()
- Add expect() assertion to receiveJson no-op test
- Skip boilerplate.css and page.css that have global selectors (body, html, div)
  which leak into host page now that we're not in an iframe
- Remove body/figure CSS rules from css_content (iframe-only)
- Fix CSS pixel calculation to use canvas.get_width_height() for SubFigure compat
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Collaborator

@dmadisetti dmadisetti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SO much better than spawning up a server. This clears a ton of smells.

I want to maybe test this out a tiny bit- the only comments I have in the meantime are:

but that's it

@mscolnick
Copy link
Contributor Author

@dmadisetti, i moved the CSS scope to a variable and simplified it greatly. also added the comment in code. going to merge but feel free to check out if you have more comments

@mscolnick mscolnick merged commit 45e12f3 into main Mar 11, 2026
33 of 43 checks passed
@mscolnick mscolnick deleted the ms/beter-mpl branch March 11, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

3 participants