shiny-plotly 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/.gitignore +1 -0
  2. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/CHANGELOG.md +28 -0
  3. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/PKG-INFO +49 -18
  4. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/README.md +47 -15
  5. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/core_app.py +1 -4
  6. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/pyproject.toml +8 -7
  7. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/__init__.py +6 -1
  8. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_html.py +10 -4
  9. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_render.py +84 -23
  10. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_serve.py +95 -17
  11. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/www/shiny-plotly.js +65 -26
  12. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/apps.py +143 -0
  13. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/conftest.py +6 -0
  14. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_browser.py +93 -3
  15. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_dark_mode.py +82 -0
  16. shiny_plotly-0.4.0/tests/helpers.py +25 -0
  17. shiny_plotly-0.4.0/tests/test_compressed_js.py +372 -0
  18. shiny_plotly-0.4.0/tests/test_examples.py +50 -0
  19. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_fig_to_ui.py +10 -0
  20. shiny_plotly-0.4.0/tests/test_pages_contract.py +50 -0
  21. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_render_plotly.py +244 -33
  22. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_update.py +1 -15
  23. shiny_plotly-0.3.2/tests/test_compressed_js.py +0 -204
  24. shiny_plotly-0.3.2/tests/test_examples.py +0 -41
  25. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/LICENSE +0 -0
  26. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/dark_app.py +0 -0
  27. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/events_app.py +0 -0
  28. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/express_app.py +0 -0
  29. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/shinylive/app.py +0 -0
  30. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/shinylive/requirements.txt +0 -0
  31. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/examples/streaming_app.py +0 -0
  32. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_deps.py +0 -0
  33. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/_update.py +0 -0
  34. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/src/shiny_plotly/py.typed +0 -0
  35. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/__init__.py +0 -0
  36. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_events.py +0 -0
  37. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_shinylive_demo.py +0 -0
  38. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_streaming_example.py +0 -0
  39. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/browser/test_update.py +0 -0
  40. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/newplot.py +0 -0
  41. {shiny_plotly-0.3.2 → shiny_plotly-0.4.0}/tests/test_plotly_js.py +0 -0
@@ -6,5 +6,6 @@ __pycache__/
6
6
  *.pyc
7
7
  .pytest_cache/
8
8
  .ruff_cache/
9
+ .coverage
9
10
  tmp/
10
11
  site/
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0](https://github.com/rvben/shiny-plotly/compare/v0.3.3...v0.4.0) - 2026-08-21
11
+
12
+ ### Changed
13
+
14
+ - `brotli` is a dependency, so a plain `uv add shiny-plotly` serves plotly.min.js in the smaller encoding (1.2 MB on the wire against 1.5 MB gzipped) instead of only where someone thought to ask for it. It carries the marker `sys_platform != 'emscripten'`: under pyodide the compressing route is never installed, so a shinylive export would have downloaded a 307 kB wasm wheel to compress nothing. An install that still ends up without brotli (a lock file older than this release, a platform with no wheel) falls back to gzip and logs the same one-off warning as before, now naming the fix.
15
+ - README: the note on hidden outputs now names every container Shiny defers a chart in (an inactive `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden` panel, a closed `ui.accordion` section, and a false `ui.panel_conditional` from Shiny 1.6.1 on, which is where that last one changed), and says what it does not cover: a chart scrolled below the fold is drawn with the rest at load. A browser test holds every case, so a Shiny release that changes the behavior breaks the suite rather than the advice.
16
+
17
+ ### Fixed
18
+
19
+ - A client that refuses an encoding with `Accept-Encoding: br;q=0.0` is no longer served it. RFC 9110 lets a qvalue of zero be written `q=0`, `q=0.0` or `q=0.000`, and only the first spelling was read as a refusal, so plotly.min.js could arrive in an encoding the client had said it would not take.
20
+
21
+ ### Removed
22
+
23
+ - The `brotli` extra, now that brotli is a plain dependency. An install that still names it (`shiny-plotly[brotli]`) gets the same package, plus a warning from pip or uv that the extra does not exist; drop the `[brotli]`.
24
+
25
+ ## [0.3.3](https://github.com/rvben/shiny-plotly/compare/v0.3.2...v0.3.3) - 2026-08-21
26
+
27
+ ### Added
28
+
29
+ - A themed chart takes its color mode from the nearest ancestor that carries `data-bs-theme`, itself included, instead of from `<html>` alone. Bootstrap honors the attribute on any element, so `ui.div(..., data_bs_theme="dark")` around part of a page now themes the charts inside it while the rest of the page follows the page mode. The observer watches the whole subtree, so the attribute can be set on a container after the charts are drawn; that is all a control of your own needs to drive the mode, and the README carries the recipe.
30
+ - A warning, said once per process, when `plotly.min.js` is served gzipped because `brotli` is not installed: it names the size being served and the roughly 17% brotli would save. Without it a deployment has no way to notice, since the bundle is compressed, cached and immutable either way, just bigger. Silence it with `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)`.
31
+ - README: what each chart on a page costs to draw, and that Shiny suspends outputs hidden in an inactive `ui.nav_panel`, so charts in tabs are drawn when their tab is first opened rather than on load.
32
+
33
+ ### Changed
34
+
35
+ - Every app serves plotly.min.js compressed and immutable from its first request, with no call of its own: importing `shiny_plotly` wraps `shiny.App.__init__`, so the route is added as each app is built, Core and Express alike. Until now it arrived with the first session, which is after the page load that opened that session had already pulled 4.9 MB from Shiny's static mount with no `Cache-Control` at all; the background compression now starts while the app is being built instead of when the first visitor is already waiting. `enable_compressed_plotly_js(app)` stays for an app constructed before the import, `SHINY_PLOTLY_NO_COMPRESS=1` still leaves Shiny's static serving untouched, and a failure to install the route is logged rather than raised: it can never keep an app from being built.
36
+ - The templates behind `@render_plotly(theme=...)` travel once per session instead of once per figure. A template is about 6.5 kB, and every chart on a page usually shares one pair, so the first themed value carries the pair and every value afterwards names it by content hash: a page of ten charts sends about 13 kB of templates instead of 135 kB. Two outputs on different themes each send their own pair, a module shares the cache with the page around it, and a reconnecting browser gets a new session and so a fresh copy. A render with no session to cache against (a value produced outside a session, or Express's stub session before it connects) still carries its templates inline.
37
+
10
38
  ## [0.3.2](https://github.com/rvben/shiny-plotly/compare/v0.3.1...v0.3.2) - 2026-08-20
11
39
 
12
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shiny-plotly
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer.
5
5
  Project-URL: Homepage, https://github.com/rvben/shiny-plotly
6
6
  Project-URL: Repository, https://github.com/rvben/shiny-plotly
@@ -22,11 +22,10 @@ Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Topic :: Scientific/Engineering :: Visualization
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.10
25
+ Requires-Dist: brotli>=1.1; sys_platform != 'emscripten'
25
26
  Requires-Dist: htmltools>=0.5.2
26
27
  Requires-Dist: plotly>=5.5
27
28
  Requires-Dist: shiny>=1.0
28
- Provides-Extra: brotli
29
- Requires-Dist: brotli>=1.1; extra == 'brotli'
30
29
  Description-Content-Type: text/markdown
31
30
 
32
31
  # shiny-plotly
@@ -206,6 +205,12 @@ The rules mirror `output_widget`:
206
205
 
207
206
  Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
208
207
 
208
+ ### Many charts on one page
209
+
210
+ Drawing a plotly figure costs the browser a fixed amount of main-thread work per graph, tens of milliseconds for a small one on a current desktop, and the browser draws them one after another. On a dashboard of a dozen charts that per-chart work, not the bytes on the wire, is what the first second is spent on, and it is plotly's own cost: the same figure drawn from shinywidgets or from a static `to_html` export costs the same.
211
+
212
+ The lever is drawing fewer charts at once, and Shiny pulls it for you: an output the browser reports as hidden is suspended, so its figure is not rendered at all until it is shown. That covers every container that hides one, whether an inactive panel of `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden`, a closed `ui.accordion` section, or a `ui.panel_conditional` whose condition is false (that last one from Shiny 1.6.1 on; older Shiny drew it at load); each panel then pays only for its own charts, and pays when it is opened. Scrolling is not hiding, though: a chart 3000px down the page is visible as far as the browser is concerned, and is drawn with the rest at load. Charts that must all be visible at once are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
213
+
209
214
  ### Dark mode
210
215
 
211
216
  Plotly does not follow Bootstrap's color mode by itself. `theme="auto"` makes the figure follow it in the browser, with no server round-trip:
@@ -229,7 +234,40 @@ def sales():
229
234
  def sales(): ...
230
235
  ```
231
236
 
232
- How it works: both templates travel with the figure, their `paper_bgcolor` and `plot_bgcolor` made transparent so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. The mode is `data-bs-theme` on `<html>`, which is what `ui.input_dark_mode()` maintains, or the OS `prefers-color-scheme` on a page without that attribute. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
237
+ How it works: both templates get their `paper_bgcolor` and `plot_bgcolor` made transparent, so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
238
+
239
+ A template is about 6.5 kB of JSON, so it is sent **once per session** rather than once per render: the first themed value of a session brings the templates over their own message, and every value after it names them by content hash. A dashboard of twelve charts on one theme therefore pays for the theme once, and a re-render of a small figure is not dominated by a template that has not changed. Two outputs whose templates are equal share the copy automatically; nothing has to be declared.
240
+
241
+ The mode comes from `data-bs-theme` on the **nearest ancestor of the output that sets one**. Usually that is `<html>`, which is what `ui.input_dark_mode()` maintains, and a page without the attribute anywhere follows the OS `prefers-color-scheme`. Because the lookup is per output, a container can theme the charts inside it against the rest of the page:
242
+
243
+ ```python
244
+ ui.div(
245
+ ui.card(output_plotly("preview")), # always dark, whatever the page does
246
+ data_bs_theme="dark",
247
+ )
248
+ ```
249
+
250
+ **Driving the mode yourself.** The attribute is the whole contract, so anything that sets it switches the charts: no shiny-plotly API is involved. To drive it from your own control, write it and the graphs follow on the next frame:
251
+
252
+ ```python
253
+ app_ui = ui.page_fluid(
254
+ ui.input_switch("night", "Night mode"),
255
+ ui.tags.script("""
256
+ Shiny.addCustomMessageHandler("color-mode", function (message) {
257
+ document.documentElement.setAttribute("data-bs-theme", message.mode);
258
+ });
259
+ """),
260
+ ui.card(output_plotly("sales")),
261
+ )
262
+
263
+
264
+ @reactive.effect
265
+ async def _apply_mode():
266
+ mode = "dark" if input.night() else "light"
267
+ await session.send_custom_message("color-mode", {"mode": mode})
268
+ ```
269
+
270
+ The same holds in reverse: a page that already themes itself (a CSS framework, a cookie read at startup, `ui.input_dark_mode()` in a nav bar) needs nothing added, and a chart rendered while the attribute is already `dark` comes up dark on its first draw rather than flashing light first.
233
271
 
234
272
  The manual alternative, picking the template on the server, still works and is the way to vary anything beyond the template per mode. Give the dark mode switch an id and read it in the render function; flipping the switch then re-renders the figure through `Plotly.react`:
235
273
 
@@ -373,29 +411,22 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
373
411
  - `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
374
412
  - `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
375
413
  - `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
376
- - `enable_compressed_plotly_js(app)` turns on compressed, immutable serving of plotly.js for a `shiny.App` before its first session (see below).
414
+ - `enable_compressed_plotly_js(app)` adds compressed, immutable serving of plotly.js to a `shiny.App` explicitly. Every app built after `shiny_plotly` is imported already has it; this is the way in for one that was constructed before the import (see below).
377
415
  - `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
378
416
 
379
417
  `render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
380
418
 
381
419
  ### plotly.js on the wire
382
420
 
383
- Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so once the first session of a process has rendered a figure, `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
421
+ Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli, 1.2 MB on the wire, or gzip at 1.5 MB where brotli is not installed) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
384
422
 
385
- ```sh
386
- uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
387
- ```
388
-
389
- Two things to know. The page load that starts the very first session of a process has already asked for the bundle before the route exists, so that one visitor gets the raw file from Shiny's mount; everyone after gets the compressed one. A Core app can close that gap by enabling the route as soon as the `App` exists:
423
+ `brotli` is a dependency, so a plain `uv add shiny-plotly` serves the smaller encoding. It is skipped under pyodide, where there is nothing to compress: a shinylive export carries its own assets and the route is not installed at all. An install that ends up without it (a lock file that predates the dependency, a platform with no wheel) falls back to gzip and logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
390
424
 
391
- ```python
392
- from shiny_plotly import enable_compressed_plotly_js
425
+ The route asks nothing of the app. Importing `shiny_plotly` wraps `shiny.App.__init__`, so every app built afterwards has it, Core and Express alike, and the compression starts while the app is still being built rather than when someone first visits it. The timing is the whole point: the browser asks for plotly.js while the page is loading, well before the session that page opens exists, so a route that waited for a session would arrive one visitor too late, and that visitor would take 4.9 MB with no `Cache-Control` at all.
393
426
 
394
- app = App(app_ui, server)
395
- enable_compressed_plotly_js(app)
396
- ```
427
+ An app constructed before `shiny_plotly` is imported is the one case the constructor cannot reach; `enable_compressed_plotly_js(app)` adds the route to it, and returns `False` if it is already there.
397
428
 
398
- And if a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment; `enable_compressed_plotly_js` then returns `False` and adds nothing.
429
+ If a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment: no route is added, and `enable_compressed_plotly_js` returns `False`.
399
430
 
400
431
  ### Shinylive
401
432
 
@@ -422,7 +453,7 @@ make bench # the shinywidgets comparison above, on this machine
422
453
  make bench-events # what a selection over a dense trace costs, capped and uncapped
423
454
  ```
424
455
 
425
- `make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
456
+ `make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. It fails below 100% line and branch coverage of the package: every line is reachable without a browser, and the gate is what keeps behavior that only the Chromium suite can reach from growing. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
426
457
 
427
458
  ## License
428
459
 
@@ -175,6 +175,12 @@ The rules mirror `output_widget`:
175
175
 
176
176
  Plotly alone re-measures a graph only on window resize. `shiny-plotly` ships a small helper script (`shiny-plotly.js`, loaded with every output) that observes each graph's container with a `ResizeObserver`, so a card that changes size without a window resize, for example when a sibling output renders below it, or when a sidebar collapses, re-lays the graph out. The same helper purges a graph once it leaves the document, which releases the window listener and layout state plotly would otherwise keep.
177
177
 
178
+ ### Many charts on one page
179
+
180
+ Drawing a plotly figure costs the browser a fixed amount of main-thread work per graph, tens of milliseconds for a small one on a current desktop, and the browser draws them one after another. On a dashboard of a dozen charts that per-chart work, not the bytes on the wire, is what the first second is spent on, and it is plotly's own cost: the same figure drawn from shinywidgets or from a static `to_html` export costs the same.
181
+
182
+ The lever is drawing fewer charts at once, and Shiny pulls it for you: an output the browser reports as hidden is suspended, so its figure is not rendered at all until it is shown. That covers every container that hides one, whether an inactive panel of `ui.navset_tab`, `ui.navset_card_tab`, `ui.navset_pill` or `ui.navset_hidden`, a closed `ui.accordion` section, or a `ui.panel_conditional` whose condition is false (that last one from Shiny 1.6.1 on; older Shiny drew it at load); each panel then pays only for its own charts, and pays when it is opened. Scrolling is not hiding, though: a chart 3000px down the page is visible as far as the browser is concerned, and is drawn with the rest at load. Charts that must all be visible at once are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
183
+
178
184
  ### Dark mode
179
185
 
180
186
  Plotly does not follow Bootstrap's color mode by itself. `theme="auto"` makes the figure follow it in the browser, with no server round-trip:
@@ -198,7 +204,40 @@ def sales():
198
204
  def sales(): ...
199
205
  ```
200
206
 
201
- How it works: both templates travel with the figure, their `paper_bgcolor` and `plot_bgcolor` made transparent so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. The mode is `data-bs-theme` on `<html>`, which is what `ui.input_dark_mode()` maintains, or the OS `prefers-color-scheme` on a page without that attribute. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
207
+ How it works: both templates get their `paper_bgcolor` and `plot_bgcolor` made transparent, so the card's own background shows through in both modes (backgrounds set on the figure's layout still win). The browser applies the mode's template before the first draw and switches it with `Plotly.relayout` when the mode flips, so the switch is instant and works even while the server is busy. A template the figure baked in through `layout.template` is dropped for themed outputs; use `theme=None` (the default) where the figure's own template should stand.
208
+
209
+ A template is about 6.5 kB of JSON, so it is sent **once per session** rather than once per render: the first themed value of a session brings the templates over their own message, and every value after it names them by content hash. A dashboard of twelve charts on one theme therefore pays for the theme once, and a re-render of a small figure is not dominated by a template that has not changed. Two outputs whose templates are equal share the copy automatically; nothing has to be declared.
210
+
211
+ The mode comes from `data-bs-theme` on the **nearest ancestor of the output that sets one**. Usually that is `<html>`, which is what `ui.input_dark_mode()` maintains, and a page without the attribute anywhere follows the OS `prefers-color-scheme`. Because the lookup is per output, a container can theme the charts inside it against the rest of the page:
212
+
213
+ ```python
214
+ ui.div(
215
+ ui.card(output_plotly("preview")), # always dark, whatever the page does
216
+ data_bs_theme="dark",
217
+ )
218
+ ```
219
+
220
+ **Driving the mode yourself.** The attribute is the whole contract, so anything that sets it switches the charts: no shiny-plotly API is involved. To drive it from your own control, write it and the graphs follow on the next frame:
221
+
222
+ ```python
223
+ app_ui = ui.page_fluid(
224
+ ui.input_switch("night", "Night mode"),
225
+ ui.tags.script("""
226
+ Shiny.addCustomMessageHandler("color-mode", function (message) {
227
+ document.documentElement.setAttribute("data-bs-theme", message.mode);
228
+ });
229
+ """),
230
+ ui.card(output_plotly("sales")),
231
+ )
232
+
233
+
234
+ @reactive.effect
235
+ async def _apply_mode():
236
+ mode = "dark" if input.night() else "light"
237
+ await session.send_custom_message("color-mode", {"mode": mode})
238
+ ```
239
+
240
+ The same holds in reverse: a page that already themes itself (a CSS framework, a cookie read at startup, `ui.input_dark_mode()` in a nav bar) needs nothing added, and a chart rendered while the attribute is already `dark` comes up dark on its first draw rather than flashing light first.
202
241
 
203
242
  The manual alternative, picking the template on the server, still works and is the way to vary anything beyond the template per mode. Give the dark mode switch an id and read it in the render function; flipping the switch then re-renders the figure through `Plotly.react`:
204
243
 
@@ -342,29 +381,22 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
342
381
  - `plotly_js()` is the `HTMLDependency` for plotly.js, served from the installed `plotly` wheel at `/lib/plotly-<version>/plotly.min.js`. Every `output_plotly` and every `fig_to_ui` fragment carries it, so it is optional; add it to the page UI when the first figure is inserted later (`ui.insert_ui`, a `@render.ui` that starts empty) and the bundle should load with the page.
343
382
  - `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
344
383
  - `FIGUREWIDGET_MARGINS` is the `{"l": 16, "t": 32, "r": 16, "b": 16}` mapping.
345
- - `enable_compressed_plotly_js(app)` turns on compressed, immutable serving of plotly.js for a `shiny.App` before its first session (see below).
384
+ - `enable_compressed_plotly_js(app)` adds compressed, immutable serving of plotly.js to a `shiny.App` explicitly. Every app built after `shiny_plotly` is imported already has it; this is the way in for one that was constructed before the import (see below).
346
385
  - `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
347
386
 
348
387
  `render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
349
388
 
350
389
  ### plotly.js on the wire
351
390
 
352
- Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so once the first session of a process has rendered a figure, `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli when the `brotli` package is installed, gzip otherwise; 1.2 MB or 1.5 MB on the wire) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
391
+ Shiny serves HTML dependencies from a plain static mount: no compression, no `Cache-Control`. `plotly.min.js` is 4.9 MB, so `shiny-plotly` adds a route in front of that mount for the bundle's exact path (`/lib/plotly-<version>/plotly.min.js`) that serves it pre-compressed (brotli, 1.2 MB on the wire, or gzip at 1.5 MB where brotli is not installed) with `Cache-Control: public, max-age=31536000, immutable`, `Vary: Accept-Encoding` and an `ETag` per encoding. The URL is keyed by the plotly version, so a browser fetches each version once. Compression runs once per process, in a background thread; until it has finished the route serves the raw file with the same headers.
353
392
 
354
- ```sh
355
- uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
356
- ```
357
-
358
- Two things to know. The page load that starts the very first session of a process has already asked for the bundle before the route exists, so that one visitor gets the raw file from Shiny's mount; everyone after gets the compressed one. A Core app can close that gap by enabling the route as soon as the `App` exists:
393
+ `brotli` is a dependency, so a plain `uv add shiny-plotly` serves the smaller encoding. It is skipped under pyodide, where there is nothing to compress: a shinylive export carries its own assets and the route is not installed at all. An install that ends up without it (a lock file that predates the dependency, a platform with no wheel) falls back to gzip and logs one warning saying which encoding it is serving and what brotli would save, so a deployment can see it is shipping the larger bundle; `logging.getLogger("shiny_plotly").setLevel(logging.ERROR)` silences it.
359
394
 
360
- ```python
361
- from shiny_plotly import enable_compressed_plotly_js
395
+ The route asks nothing of the app. Importing `shiny_plotly` wraps `shiny.App.__init__`, so every app built afterwards has it, Core and Express alike, and the compression starts while the app is still being built rather than when someone first visits it. The timing is the whole point: the browser asks for plotly.js while the page is loading, well before the session that page opens exists, so a route that waited for a session would arrive one visitor too late, and that visitor would take 4.9 MB with no `Cache-Control` at all.
362
396
 
363
- app = App(app_ui, server)
364
- enable_compressed_plotly_js(app)
365
- ```
397
+ An app constructed before `shiny_plotly` is imported is the one case the constructor cannot reach; `enable_compressed_plotly_js(app)` adds the route to it, and returns `False` if it is already there.
366
398
 
367
- And if a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment; `enable_compressed_plotly_js` then returns `False` and adds nothing.
399
+ If a reverse proxy in front of the app does its own compression and caching, or you want Shiny's static serving untouched for any reason, set `SHINY_PLOTLY_NO_COMPRESS=1` in the app's environment: no route is added, and `enable_compressed_plotly_js` returns `False`.
368
400
 
369
401
  ### Shinylive
370
402
 
@@ -391,7 +423,7 @@ make bench # the shinywidgets comparison above, on this machine
391
423
  make bench-events # what a selection over a dense trace costs, capped and uncapped
392
424
  ```
393
425
 
394
- `make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
426
+ `make test` runs the unit tests and the in-process Shiny end-to-end tests over a real websocket, including the compressed bundle route. It fails below 100% line and branch coverage of the package: every line is reachable without a browser, and the gate is what keeps behavior that only the Chromium suite can reach from growing. `make test-browser` drives the package in headless Chromium: fill sizing, resize without a window event, the graph div surviving a re-render, `uirevision` keeping a dragged zoom, purge once an output leaves the page, full screen, `events=` click, hover, selection and relayout inputs (attached once, also inside a module, a selection above `max_event_points` arriving as count and range), `extend_traces`, `restyle` and `relayout` applied in place (rolling window, one trace or all, held until the first draw, reset by a re-render, inside a module, dropped with a warning for an unknown output), `post_script` click wiring (once, not stacked), the dark mode recipe, error and `None` rendering, on-demand loading of plotly.js and the compressed, cached bundle as a fresh visitor sees it. `make check-wheel` installs the built wheel into a throwaway venv and runs the suite against it, so the published artifact is what was tested. `make check-floor` installs the package with plotly, shiny and htmltools at the oldest versions `pyproject.toml` allows and runs the whole suite again, browser tests included, so the declared lower bounds are tested on every push rather than assumed.
395
427
 
396
428
  ## License
397
429
 
@@ -9,7 +9,7 @@ from itertools import accumulate
9
9
  import plotly.graph_objects as go
10
10
  from shiny import App, Inputs, Outputs, Session, reactive, render, ui
11
11
 
12
- from shiny_plotly import enable_compressed_plotly_js, extend_traces, output_plotly, render_plotly
12
+ from shiny_plotly import extend_traces, output_plotly, render_plotly
13
13
 
14
14
  app_ui = ui.page_sidebar(
15
15
  ui.sidebar(
@@ -99,6 +99,3 @@ def server(input: Inputs, output: Outputs, session: Session):
99
99
 
100
100
 
101
101
  app = App(app_ui, server)
102
- # Optional: serve plotly.js compressed from the very first request instead of from the
103
- # first session on (see README, "plotly.js on the wire").
104
- enable_compressed_plotly_js(app)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "shiny-plotly"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Render plotly figures in Shiny for Python with plain plotly.js, without the shinywidgets layer."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -33,12 +33,12 @@ dependencies = [
33
33
  "shiny>=1.0",
34
34
  "plotly>=5.5",
35
35
  "htmltools>=0.5.2",
36
+ # plotly.min.js is served brotli-compressed, about 17% smaller than gzip. Under pyodide
37
+ # there is nothing to compress (the bundle comes from the shinylive export) and the route
38
+ # is skipped, so the marker keeps a 307 kB wasm wheel out of every exported app.
39
+ "brotli>=1.1; sys_platform != 'emscripten'",
36
40
  ]
37
41
 
38
- [project.optional-dependencies]
39
- # plotly.min.js is served brotli-compressed (about 15% smaller than gzip) when present.
40
- brotli = ["brotli>=1.1"]
41
-
42
42
  [project.urls]
43
43
  Homepage = "https://github.com/rvben/shiny-plotly"
44
44
  Repository = "https://github.com/rvben/shiny-plotly"
@@ -52,12 +52,13 @@ bench = [
52
52
  ]
53
53
  dev = [
54
54
  "pytest>=8",
55
+ "pytest-cov>=5",
55
56
  "ruff>=0.6",
56
57
  "pyright>=1.1.380",
57
58
  "httpx2>=2.12.0",
58
59
  "pytest-playwright>=0.9.0",
59
60
  "numpy>=1.26",
60
- "brotli>=1.1",
61
+ "shinylive-check>=0.1.0",
61
62
  ]
62
63
 
63
64
  # uv's default cache keys for a local build are the metadata files only, so a source-only
@@ -90,4 +91,4 @@ pythonVersion = "3.10"
90
91
  typeCheckingMode = "standard"
91
92
 
92
93
  [tool.ruff.lint.isort]
93
- known-local-folder = ["newplot"]
94
+ known-local-folder = ["helpers", "newplot"]
@@ -3,7 +3,7 @@
3
3
  from ._deps import __version__, plotly_js, shiny_plotly_js
4
4
  from ._html import FIGUREWIDGET_MARGINS, fig_to_ui
5
5
  from ._render import DEFAULT_MAX_EVENT_POINTS, output_plotly, render_plotly
6
- from ._serve import enable_compressed_plotly_js
6
+ from ._serve import enable_compressed_plotly_js, enable_for_new_apps
7
7
  from ._update import (
8
8
  add_traces,
9
9
  delete_traces,
@@ -32,3 +32,8 @@ __all__ = (
32
32
  "shiny_plotly_js",
33
33
  "update",
34
34
  )
35
+
36
+ # From here on every app serves plotly.min.js compressed and immutable, from its first
37
+ # request, with no call of its own; the compression starts while the app is being built.
38
+ # SHINY_PLOTLY_NO_COMPRESS=1 in the environment leaves Shiny's static serving untouched.
39
+ enable_for_new_apps()
@@ -103,9 +103,15 @@ def fig_to_ui(
103
103
 
104
104
 
105
105
  def as_fig_dict(fig: Figure) -> dict[str, Any]:
106
- # Figure.to_dict() does no validation (the figure was validated when built), and a
107
- # dict is passed through as the caller's JSON; pio.to_html gets validate=False so it
108
- # never reconstructs a Figure from it.
106
+ """
107
+ The figure as a plain dict carrying a ``layout`` dict, whichever way it was given.
108
+
109
+ Everything downstream reaches into ``layout`` (to fill in margins, to drop a baked-in
110
+ template), so it is made a dict here rather than guarded against at each of them.
111
+ """
112
+ # Figure.to_dict() does no validation (the figure was validated when built), and always
113
+ # carries a layout; a dict is passed through as the caller's JSON, so pio.to_html gets
114
+ # validate=False and never reconstructs a Figure from it.
109
115
  if isinstance(fig, BaseFigure):
110
116
  return fig.to_dict()
111
117
  if isinstance(fig, dict):
@@ -116,5 +122,5 @@ def as_fig_dict(fig: Figure) -> dict[str, Any]:
116
122
 
117
123
 
118
124
  def fill_in_margins(fig_dict: dict[str, Any]) -> None:
119
- layout = fig_dict.setdefault("layout", {})
125
+ layout = fig_dict["layout"]
120
126
  layout["margin"] = {**FIGUREWIDGET_MARGINS, **(layout.get("margin") or {})}
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Iterable, Mapping
3
+ import hashlib
4
+ import weakref
5
+ from collections.abc import Iterable, Mapping, MutableMapping
4
6
  from typing import Any
5
7
 
6
8
  import plotly.io as pio
@@ -8,6 +10,7 @@ from htmltools import Tag, css, tags
8
10
  from plotly.io.json import to_json_plotly
9
11
  from shiny.module import resolve_id
10
12
  from shiny.render.renderer import Jsonifiable, Renderer, ValueFn
13
+ from shiny.session import Session, get_current_session
11
14
  from shiny.ui.fill import as_fill_item, as_fillable_container
12
15
 
13
16
  from ._deps import plotly_js, shiny_plotly_js
@@ -102,6 +105,27 @@ def _template_json(spec: TemplateSpec) -> dict[str, Any]:
102
105
  return out
103
106
 
104
107
 
108
+ # Name of the custom message carrying templates the session has not seen yet.
109
+ TEMPLATE_MESSAGE = "shiny-plotly-template"
110
+
111
+ # Keys of the templates each session has already been sent, by root session. A template is
112
+ # about 6.5 kB and every chart on a page usually shares the same one, so it travels once
113
+ # per session and the values afterwards carry only its key. The session holds the set, and
114
+ # a browser that reconnects gets a new session, so the server can never believe the browser
115
+ # has a template it does not; a weak key lets both go when the session ends.
116
+ _sent_templates: MutableMapping[Session, set[str]] = weakref.WeakKeyDictionary()
117
+
118
+
119
+ def template_body(spec: TemplateSpec) -> str:
120
+ """One template as the JSON it travels as, through plotly's own encoder."""
121
+ return str(to_json_plotly(_template_json(spec)))
122
+
123
+
124
+ def template_key(template_json: str) -> str:
125
+ """Content address of one serialized template; equal templates share one key."""
126
+ return hashlib.sha256(template_json.encode(), usedforsecurity=False).hexdigest()[:16]
127
+
128
+
105
129
  def normalize_max_event_points(value: int | None) -> int | None:
106
130
  if value is None:
107
131
  return None
@@ -211,14 +235,17 @@ class render_plotly(Renderer[Figure]):
211
235
  round-trip. ``"auto"`` uses plotly's own pair: the ``"plotly"`` template in light
212
236
  mode and ``"plotly_dark"`` in dark. A ``(light, dark)`` pair picks the templates,
213
237
  each a registered name (``"seaborn"``), a plotly ``Template`` object or a template
214
- dict. Both templates travel with the figure, with transparent ``paper_bgcolor``
215
- and ``plot_bgcolor`` so the page shows through (a figure-level background still
216
- wins); a template the figure baked in via ``layout.template`` is dropped. The
217
- browser applies the mode's template before the first draw and switches it with
218
- ``Plotly.relayout`` when the mode flips: it follows ``data-bs-theme`` on ``<html>``
219
- (what ``ui.input_dark_mode()`` sets) when present, the OS ``prefers-color-scheme``
220
- otherwise. Template names resolve when the decorator runs; an unknown name raises
221
- right there. Default ``None``: the figure's own template, fixed.
238
+ dict. Both templates get transparent ``paper_bgcolor`` and ``plot_bgcolor`` so the
239
+ page shows through (a figure-level background still wins), and a template the
240
+ figure baked in via ``layout.template`` is dropped. Each template travels once per
241
+ session, so a page of charts sharing a theme sends one copy of it and every render
242
+ after that carries only its key. The browser applies the mode's template before the
243
+ first draw and switches it with ``Plotly.relayout`` when the mode flips: the mode is
244
+ the ``data-bs-theme`` of the output's nearest ancestor that sets one (what
245
+ ``ui.input_dark_mode()`` sets on ``<html>``, or a container that themes part of a
246
+ page), the OS ``prefers-color-scheme`` otherwise. Template names resolve when the
247
+ decorator runs; an unknown name raises right there. Default ``None``: the figure's
248
+ own template, fixed.
222
249
  """
223
250
 
224
251
  def __init__(
@@ -242,15 +269,21 @@ class render_plotly(Renderer[Figure]):
242
269
  self.events = normalize_events(events)
243
270
  self.max_event_points = normalize_max_event_points(max_event_points)
244
271
  self.theme = normalize_theme(theme)
245
- if self.theme is None:
246
- self._themes_json = None
247
- else:
248
- # Resolved and serialized once, here, so an unknown name fails at decoration
249
- # time and a render costs nothing extra.
250
- light, dark = self.theme
251
- self._themes_json = to_json_plotly(
252
- {"light": _template_json(light), "dark": _template_json(dark)}
253
- )
272
+ # Resolved and serialized once, here, so an unknown name fails at decoration time
273
+ # and a render costs nothing extra.
274
+ self._theme_templates: dict[str, str] = {}
275
+ self._theme_keys: dict[str, Any] | None = None
276
+ self._themes_json: str | None = None
277
+ if self.theme is not None:
278
+ light, dark = (template_body(spec) for spec in self.theme)
279
+ self._theme_keys = {"light": template_key(light), "dark": template_key(dark)}
280
+ self._theme_templates = {
281
+ self._theme_keys["light"]: light,
282
+ self._theme_keys["dark"]: dark,
283
+ }
284
+ # The inline form, for a render with no session to cache against. Built from
285
+ # the same two strings, so both paths carry byte-identical templates.
286
+ self._themes_json = f'{{"light":{light},"dark":{dark}}}'
254
287
  # Registers _fn (sets output_id from its name) when used as a bare decorator.
255
288
  super().__init__(_fn) # type: ignore[arg-type]
256
289
 
@@ -265,16 +298,43 @@ class render_plotly(Renderer[Figure]):
265
298
  def auto_output_ui(self) -> Tag:
266
299
  return output_plotly(self.output_id)
267
300
 
301
+ async def _cache_templates(self) -> dict[str, Any] | None:
302
+ """
303
+ The keys of this renderer's templates, once the session is known to hold them.
304
+
305
+ ``None`` when there is no session to cache against (a render outside a session, or
306
+ Express's stub session, which drops custom messages), and the templates then travel
307
+ inline with the value instead.
308
+ """
309
+ session = get_current_session()
310
+ if session is None or session.is_stub_session():
311
+ return None
312
+ sent = _sent_templates.setdefault(session.root_scope(), set())
313
+ new = {key: body for key, body in self._theme_templates.items() if key not in sent}
314
+ if new:
315
+ # Sent from inside the render, so it is its own websocket message ahead of the
316
+ # one carrying the values: the browser has the template before it is asked to
317
+ # draw with it. Batching it into the value message would invert that, since
318
+ # Shiny dispatches its custom handlers after its output values.
319
+ await session.send_custom_message(TEMPLATE_MESSAGE, {"templates": new})
320
+ # Recorded only once the send has gone through. A repeat send costs a message
321
+ # the browser overwrites with the same bytes, while a key recorded for a
322
+ # template that never arrived would leave the figure unthemed for the session.
323
+ sent.update(new)
324
+ return self._theme_keys
325
+
268
326
  async def transform(self, value: Figure) -> Jsonifiable:
269
327
  fig_dict = as_fig_dict(value)
270
328
  if self.figurewidget_margins:
271
329
  fill_in_margins(fig_dict)
272
- if self._themes_json is not None:
330
+ themes_json, theme_keys = self._themes_json, None
331
+ if self._theme_templates:
273
332
  # The browser picks the mode's template; the one the figure baked in at
274
333
  # construction would only add dead weight and a flash of the wrong theme.
275
- layout = fig_dict.get("layout")
276
- if isinstance(layout, dict):
277
- layout.pop("template", None)
334
+ fig_dict["layout"].pop("template", None)
335
+ theme_keys = await self._cache_templates()
336
+ if theme_keys is not None:
337
+ themes_json = None
278
338
  return {
279
339
  # Serialised by plotly, not Shiny: numpy and pandas values, datetimes and the
280
340
  # compact base64 array encoding only work through plotly's encoder.
@@ -285,5 +345,6 @@ class render_plotly(Renderer[Figure]):
285
345
  "post_script": self.post_script,
286
346
  "events": list(self.events),
287
347
  "max_event_points": self.max_event_points,
288
- "themes": self._themes_json,
348
+ "themes": themes_json,
349
+ "theme_keys": theme_keys,
289
350
  }