feat: move matplotlib interactive to use marimo comm instead of over WS#8612
feat: move matplotlib interactive to use marimo comm instead of over WS#8612
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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.pyUIElement that bridgesFigureManagerWebAggtoMarimoComm, and new frontendMplInteractivePlugin.tsx+mpl-websocket-shim.tsthat receives comm messages and drivesmpl.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")); |
There was a problem hiding this comment.
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.
| script.onerror = () => reject(new Error("Failed to load mpl.js")); | |
| script.onerror = () => { | |
| mplJsLoading = null; | |
| reject(new Error("Failed to load mpl.js")); | |
| }; |
|
|
||
| setupFigure(container).then((cleanupFn) => { | ||
| cleanup = cleanupFn; | ||
| }); | ||
|
|
||
| return () => { |
There was a problem hiding this comment.
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.
| 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; |
| 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) |
There was a problem hiding this comment.
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.
- 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
There was a problem hiding this comment.
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.
dmadisetti
left a comment
There was a problem hiding this comment.
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:
-
link to matplotlib docs/source somewhere. I needed these for review, and it'll be good to have in case of internal api changes: https://github.com/matplotlib/matplotlib/blob/9d83ca60096f313dfc9f144288501af18c770a4e/lib/matplotlib/backends/backend_webagg_core.py#L459
-
The CSS scoping maybe looks a little brittle
but that's it
|
@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 |
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
from_mpl_interactive.py— UIElement that bridgesFigureManagerWebAggtoMarimoCommMplInteractivePlugin.tsx+mpl-websocket-shim.ts— frontend plugin using MODEL_MANAGER_mpl.py— removed ~400 lines (server manager, starlette app, WS proxy, iframe template)mpl.pyendpoint,test_mpl_endpoints.py, mpl proxy fixtures from middleware testsWhat this enables