shiny-plotly 0.3.2__tar.gz → 0.3.3__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 (38) hide show
  1. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/CHANGELOG.md +13 -0
  2. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/PKG-INFO +47 -11
  3. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/README.md +46 -10
  4. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/core_app.py +1 -4
  5. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/pyproject.toml +2 -1
  6. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/__init__.py +6 -1
  7. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_render.py +83 -20
  8. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_serve.py +75 -15
  9. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/www/shiny-plotly.js +65 -26
  10. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/apps.py +70 -0
  11. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/conftest.py +6 -0
  12. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_browser.py +13 -0
  13. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_dark_mode.py +82 -0
  14. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_compressed_js.py +118 -5
  15. shiny_plotly-0.3.3/tests/test_pages_contract.py +50 -0
  16. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_render_plotly.py +193 -29
  17. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/.gitignore +0 -0
  18. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/LICENSE +0 -0
  19. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/dark_app.py +0 -0
  20. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/events_app.py +0 -0
  21. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/express_app.py +0 -0
  22. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/shinylive/app.py +0 -0
  23. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/shinylive/requirements.txt +0 -0
  24. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/examples/streaming_app.py +0 -0
  25. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_deps.py +0 -0
  26. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_html.py +0 -0
  27. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/_update.py +0 -0
  28. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/src/shiny_plotly/py.typed +0 -0
  29. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/__init__.py +0 -0
  30. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_events.py +0 -0
  31. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_shinylive_demo.py +0 -0
  32. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_streaming_example.py +0 -0
  33. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/browser/test_update.py +0 -0
  34. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/newplot.py +0 -0
  35. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_examples.py +0 -0
  36. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_fig_to_ui.py +0 -0
  37. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_plotly_js.py +0 -0
  38. {shiny_plotly-0.3.2 → shiny_plotly-0.3.3}/tests/test_update.py +0 -0
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.3](https://github.com/rvben/shiny-plotly/compare/v0.3.2...v0.3.3) - 2026-08-21
11
+
12
+ ### Added
13
+
14
+ - 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.
15
+ - 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)`.
16
+ - 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.
17
+
18
+ ### Changed
19
+
20
+ - 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.
21
+ - 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.
22
+
10
23
  ## [0.3.2](https://github.com/rvben/shiny-plotly/compare/v0.3.1...v0.3.2) - 2026-08-20
11
24
 
12
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shiny-plotly
3
- Version: 0.3.2
3
+ Version: 0.3.3
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
@@ -206,6 +206,12 @@ The rules mirror `output_widget`:
206
206
 
207
207
  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
208
 
209
+ ### Many charts on one page
210
+
211
+ 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.
212
+
213
+ The lever is drawing fewer charts at once. Shiny suspends an output that is hidden, so charts inside `ui.navset_tab` panels or `ui.accordion` sections are not rendered at all until their panel is shown, and each panel then pays only for its own charts. Charts that must all be visible are better served by fewer, denser figures (subplots in one graph div) than by many small ones.
214
+
209
215
  ### Dark mode
210
216
 
211
217
  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 +235,40 @@ def sales():
229
235
  def sales(): ...
230
236
  ```
231
237
 
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.
238
+ 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.
239
+
240
+ 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.
241
+
242
+ 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:
243
+
244
+ ```python
245
+ ui.div(
246
+ ui.card(output_plotly("preview")), # always dark, whatever the page does
247
+ data_bs_theme="dark",
248
+ )
249
+ ```
250
+
251
+ **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:
252
+
253
+ ```python
254
+ app_ui = ui.page_fluid(
255
+ ui.input_switch("night", "Night mode"),
256
+ ui.tags.script("""
257
+ Shiny.addCustomMessageHandler("color-mode", function (message) {
258
+ document.documentElement.setAttribute("data-bs-theme", message.mode);
259
+ });
260
+ """),
261
+ ui.card(output_plotly("sales")),
262
+ )
263
+
264
+
265
+ @reactive.effect
266
+ async def _apply_mode():
267
+ mode = "dark" if input.night() else "light"
268
+ await session.send_custom_message("color-mode", {"mode": mode})
269
+ ```
270
+
271
+ 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
272
 
234
273
  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
274
 
@@ -373,29 +412,26 @@ A runnable version of the streaming pattern, with a pause switch and a window sl
373
412
  - `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
413
  - `shiny_plotly_js()` is the helper's dependency. Every output and fragment carries it too.
375
414
  - `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).
415
+ - `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
416
  - `extend_traces`, `restyle` and `relayout` take an optional `session=` when called outside the current session's context.
378
417
 
379
418
  `render_plotly` needs `output_plotly`; it is an output binding, not a `render.ui`, so `ui.output_ui(id)` does not draw it.
380
419
 
381
420
  ### plotly.js on the wire
382
421
 
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.
422
+ 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 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.
384
423
 
385
424
  ```sh
386
425
  uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
387
426
  ```
388
427
 
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:
428
+ Without it the process 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
429
 
391
- ```python
392
- from shiny_plotly import enable_compressed_plotly_js
430
+ 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
431
 
394
- app = App(app_ui, server)
395
- enable_compressed_plotly_js(app)
396
- ```
432
+ 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
433
 
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.
434
+ 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
435
 
400
436
  ### Shinylive
401
437
 
@@ -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. Shiny suspends an output that is hidden, so charts inside `ui.navset_tab` panels or `ui.accordion` sections are not rendered at all until their panel is shown, and each panel then pays only for its own charts. Charts that must all be visible 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,26 @@ 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 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.
353
392
 
354
393
  ```sh
355
394
  uv add "shiny-plotly[brotli]" # optional: brotli instead of gzip
356
395
  ```
357
396
 
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:
397
+ Without it the process 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
398
 
360
- ```python
361
- from shiny_plotly import enable_compressed_plotly_js
399
+ 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
400
 
363
- app = App(app_ui, server)
364
- enable_compressed_plotly_js(app)
365
- ```
401
+ 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
402
 
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.
403
+ 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
404
 
369
405
  ### Shinylive
370
406
 
@@ -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.3.3"
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"
@@ -58,6 +58,7 @@ dev = [
58
58
  "pytest-playwright>=0.9.0",
59
59
  "numpy>=1.26",
60
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
@@ -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()
@@ -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,45 @@ 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
334
  layout = fig_dict.get("layout")
276
335
  if isinstance(layout, dict):
277
336
  layout.pop("template", None)
337
+ theme_keys = await self._cache_templates()
338
+ if theme_keys is not None:
339
+ themes_json = None
278
340
  return {
279
341
  # Serialised by plotly, not Shiny: numpy and pandas values, datetimes and the
280
342
  # compact base64 array encoding only work through plotly's encoder.
@@ -285,5 +347,6 @@ class render_plotly(Renderer[Figure]):
285
347
  "post_script": self.post_script,
286
348
  "events": list(self.events),
287
349
  "max_event_points": self.max_event_points,
288
- "themes": self._themes_json,
350
+ "themes": themes_json,
351
+ "theme_keys": theme_keys,
289
352
  }
@@ -6,24 +6,27 @@ no ``Cache-Control``, so each visit moves the 4.8 MB bundle or at least revalida
6
6
  The bundle's URL is keyed by the plotly version (``/lib/plotly-<version>/plotly.min.js``),
7
7
  so it can be served with a year-long ``immutable`` cache and pre-compressed once.
8
8
 
9
- Shiny gives a package no hook into an ``App`` as it is built; the first hook is the
10
- session. So the first :class:`~shiny_plotly.render_plotly` (or :func:`~shiny_plotly.fig_to_ui`
11
- inside a session) of a process adds a route in front of Shiny's mount for the bundle's
12
- exact path, and a background thread compresses the bundle once per process. Until it
13
- has finished, the route serves the raw file, with the same cache headers. The page load
14
- that started the very first session of a process has already requested the bundle
15
- from Shiny's own mount by then; every request after it is served here.
9
+ Importing ``shiny_plotly`` wraps ``shiny.App.__init__``, so every app built after it gets a
10
+ route for the bundle's exact path in front of Shiny's mount, and a background thread starts
11
+ compressing the bundle once per process. Both happen while the app is being built, before it
12
+ can serve anything, so the first request of the process is already served here; until the
13
+ compression has finished the route serves the raw file with the same cache headers. An app
14
+ built before the import can still ask for the route with :func:`enable_compressed_plotly_js`,
15
+ and each session enables it for its own app, so it is there either way.
16
16
  """
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import functools
20
21
  import gzip
21
22
  import hashlib
23
+ import logging
22
24
  import os
23
25
  import sys
24
26
  import threading
25
27
  from collections.abc import Iterator
26
28
  from pathlib import Path
29
+ from typing import Any
27
30
 
28
31
  from starlette.requests import Request
29
32
  from starlette.responses import FileResponse, Response
@@ -31,12 +34,25 @@ from starlette.routing import Route
31
34
 
32
35
  from ._deps import plotly_js
33
36
 
34
- __all__ = ("ROUTE_NAME", "CompressedBundle", "bundle", "enable_compressed_plotly_js")
37
+ __all__ = (
38
+ "ROUTE_NAME",
39
+ "CompressedBundle",
40
+ "bundle",
41
+ "enable_compressed_plotly_js",
42
+ "enable_for_new_apps",
43
+ )
35
44
 
36
45
  ROUTE_NAME = "shiny-plotly-bundle"
37
46
  CACHE_CONTROL = "public, max-age=31536000, immutable"
38
47
  MEDIA_TYPE = "text/javascript; charset=utf-8"
39
48
 
49
+ logger = logging.getLogger(__name__)
50
+
51
+ # How much smaller brotli q9 is than gzip -9 on plotly.min.js (1.22 MB against 1.46 MB on
52
+ # plotly 6.9.0). Only used to size the warning below, which is raised precisely when brotli
53
+ # is absent and the real figure cannot be measured.
54
+ BROTLI_SAVING_PERCENT = 17
55
+
40
56
  try:
41
57
  import brotli
42
58
  except ImportError: # pragma: no cover - optional extra
@@ -81,6 +97,17 @@ class CompressedBundle:
81
97
  if brotli is not None:
82
98
  self.encodings["br"] = brotli.compress(raw, quality=9)
83
99
  self.encodings["gzip"] = gzip.compress(raw, compresslevel=9, mtime=0)
100
+ if brotli is None:
101
+ # Compression runs once per process, so this is said once. Without it a
102
+ # deployment has no way to notice it is serving the larger encoding: the
103
+ # bundle is compressed, cached and immutable either way, just bigger.
104
+ logger.warning(
105
+ "shiny-plotly is serving plotly.min.js gzipped (%.2f MB); brotli would "
106
+ "be about %d%% smaller. Install shiny-plotly[brotli] for it, or silence "
107
+ "this with logging.getLogger('shiny_plotly').setLevel(logging.ERROR).",
108
+ len(self.encodings["gzip"]) / 1e6,
109
+ BROTLI_SAVING_PERCENT,
110
+ )
84
111
  finally:
85
112
  self._ready.set()
86
113
 
@@ -152,13 +179,9 @@ def enable_compressed_plotly_js(app: object) -> bool:
152
179
  """
153
180
  Serve plotly.min.js compressed and immutable from ``app``, starting now.
154
181
 
155
- Every :class:`~shiny_plotly.render_plotly` does this for its app when its first
156
- session starts, which is too late for the page load that started that session. A Core
157
- app can call it as soon as the ``shiny.App`` exists, so the first visitor of the
158
- process gets the compressed bundle too::
159
-
160
- app = App(app_ui, server)
161
- enable_compressed_plotly_js(app)
182
+ Importing ``shiny_plotly`` already does this for every ``shiny.App`` built afterwards,
183
+ so an app needs no call of its own. It stays the way in for an app that was constructed
184
+ before the import, and it is what each session calls for its own app.
162
185
 
163
186
  Returns True when the route was added, False when it was already there, ``app`` is
164
187
  not a Shiny app, or ``SHINY_PLOTLY_NO_COMPRESS`` is set in the environment (the escape
@@ -188,6 +211,43 @@ def enable_compressed_plotly_js(app: object) -> bool:
188
211
  return True
189
212
 
190
213
 
214
+ # Marks the wrapper below, so a second import (or a module reload) wraps nothing twice.
215
+ _WRAPPED = "_shiny_plotly_wrapped"
216
+
217
+
218
+ def enable_for_new_apps() -> bool:
219
+ """
220
+ Serve the compressed bundle from every ``shiny.App`` built from now on.
221
+
222
+ Wraps ``shiny.App.__init__`` once, at import of this package, and enables the route on
223
+ each app it builds. Shiny offers a package no other hook into an app that early, and
224
+ early is the point: the browser asks for plotly.min.js while the page is loading, long
225
+ before the session that page opens exists, so a route installed by the first session is
226
+ installed one visitor too late. Express constructs the same ``App``, so it is covered too.
227
+
228
+ Returns True when the wrapper was installed, False when it was already there.
229
+ """
230
+ from shiny import App
231
+
232
+ original = App.__init__
233
+ if getattr(original, _WRAPPED, False):
234
+ return False
235
+
236
+ @functools.wraps(original)
237
+ def __init__(self: App, *args: Any, **kwargs: Any) -> None:
238
+ original(self, *args, **kwargs)
239
+ try:
240
+ enable_compressed_plotly_js(self)
241
+ except Exception:
242
+ # This app never asked for the route, so nothing here may keep it from being
243
+ # built; without it the bundle is served by Shiny's own mount, as before.
244
+ logger.warning("shiny-plotly could not serve plotly.min.js compressed", exc_info=True)
245
+
246
+ setattr(__init__, _WRAPPED, True)
247
+ App.__init__ = __init__
248
+ return True
249
+
250
+
191
251
  def enable_for_current_session() -> None:
192
252
  """Enable for the app of the active session, if any (no-op outside a session)."""
193
253
  from shiny.session import get_current_session