All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.4.0 - 2026-08-21
brotliis a dependency, so a plainuv add shiny-plotlyserves 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 markersys_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.- 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_pillorui.navset_hiddenpanel, a closedui.accordionsection, and a falseui.panel_conditionalfrom 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.
- A client that refuses an encoding with
Accept-Encoding: br;q=0.0is no longer served it. RFC 9110 lets a qvalue of zero be writtenq=0,q=0.0orq=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.
- The
brotliextra, 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].
0.3.3 - 2026-08-21
- 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, soui.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. - A warning, said once per process, when
plotly.min.jsis served gzipped becausebrotliis 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 withlogging.getLogger("shiny_plotly").setLevel(logging.ERROR). - 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.
- Every app serves plotly.min.js compressed and immutable from its first request, with no call of its own: importing
shiny_plotlywrapsshiny.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 noCache-Controlat 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=1still 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. - 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.
0.3.2 - 2026-08-20
- Three more forwardable events:
doubleclickarrives as a running count of plot-area double-clicks (plotly hands the event no data; the count's change invalidates the input);legendclickandlegenddoubleclickarrive as the clicked trace'scurve_number,expanded_index,nameand pre-togglevisible, pluslabelfor trace types whose legend items are labels. The default toggle and isolate behaviors still happen. examples/streaming_app.py: a live rolling window fed byextend_traces, with a pause switch and a window slider; a browser test drives the example file itself and proves the stream flows and pauses.- A live demo at https://rvben.github.io/shiny-plotly/:
examples/shinylive/app.pygrew into a two-tab showcase (a streaming figure, click and box-select events, a dark mode toggle), deployed to GitHub Pages by a workflow that builds the site from the checkout's own wheel (make site) and gates the deploy on a headless Chromium watching both tabs render (make site-check). The committed example still installs from PyPI, souvx shinylive export examples/shinylive sitekeeps working as documented.
0.3.1 - 2026-08-20
@render_plotly(theme="auto"): the figure follows the page's color mode in the browser, with no server round-trip."auto"pairs plotly's"plotly"and"plotly_dark"templates; a(light, dark)tuple picks others, each a registered name, aTemplateobject or a template dict. Both templates travel with the figure with transparent backgrounds so the page shows through; the browser applies the mode's template before the first draw and switches it withPlotly.relayoutwhendata-bs-theme(whatui.input_dark_mode()sets) or the OSprefers-color-schemeflips. Unknown template names raise at decoration time.- The in-place update family is complete:
add_traces,delete_traces,prepend_tracesandupdate(a restyle and a relayout in one redraw) joinextend_traces,restyleandrelayout. Same contract as the first three: values through plotly's encoder, ids namespaced inside a module, updates held and applied in order while the output has no figure drawn. - Examples for dark mode (
examples/dark_app.py), dense-trace selections (examples/events_app.py) and a ready-to-export shinylive app (examples/shinylive/); a test keeps every example building.
- Shinylive: under pyodide the compressed plotly.js route is skipped instead of crashing the session. Pyodide cannot start threads, so the compression thread raised
RuntimeError: can't start new threadfrom the first@render_plotlyof every session and no figure ever rendered; there is also nothing to serve there, as the browser loads assets from the shinylive bundle. Verified against a realshinylive export: 0.3.0 crashes, this version renders.
0.3.0 - 2026-08-20
@render_plotly(events=...)forwards plotly events to Shiny inputs: any ofclick,hover,selectedandrelayoutarrive asinput.<id>_<event>(namespaced inside a module), carrying plotly's event data cut to what serializes (pointswith each point's scalar fields pluscustomdata,bbox,pointNumbers;rangeorlassoPointsfor selections; relayout data as is). A click fires on every click; hover is debounced and becomesNonewhen the pointer leaves; a deselect setsselectedtoNone. Handlers attach once per graph div and survive re-renders.max_event_points(default 10 000) caps the points one event carries: above it the value arrives with"points": Noneandpoint_countset, itsrangeorlassoPointsintact, because a point is about 100 bytes of JSON and a box over a dense trace would otherwise build a message of many megabytes, or one above uvicorn's default 16 MB websocket limit, which closes the session (make bench-eventsmeasures it; 200 000 selected points are 20.9 MB).extend_traces(id, data, indices=None, *, max_points=None),restyle(id, update, indices=None)andrelayout(id, update): in-place updates to the figure an output holds, sent as Shiny custom messages and applied in the browser withPlotly.extendTraces,Plotly.restyleandPlotly.relayout. Values go through plotly's encoder; the id is namespaced inside a module; an update sent while the output has no figure drawn is held and applied after its next draw; a re-render replaces the figure, updates included.enable_compressed_plotly_js(app)is public: a Core app can serve plotly.js compressed and immutable from its very first request instead of from its first session on.
output_plotly()now carries the htmltools fill CSS itself. On a page that did not load it otherwise (ui.page_fluidwithout a card), a fixed-height output (output_plotly(id, height="200px")) with a bare@render_plotlydrew a 400px graph that overflowed the output and whatever sat below it.output_plotly(id)(and the Express auto output) did not namespace its id inside a Shiny module, so a@render_plotlyin a module never found its output.- The declared dependency floor was wrong:
plotly>=5.0andhtmltools>=0.5could not work (fig_to_uineedsto_html(div_id=...), which plotly 5.5 introduced; shiny 1.0 itself needs htmltools 0.5.2). The bounds are nowplotly>=5.5,htmltools>=0.5.2, and CI installs exactly that floor (with shiny 1.0) and runs the whole suite against it, browser tests included (make check-floor).
0.2.0 - 2026-08-19
@render_plotlyis now a Shiny output binding instead of arender.ui: the figure travels as plotly JSON andshiny-plotly.jsdraws it withPlotly.newPlotthe first time andPlotly.reacton every re-render, into one graph div it keeps. Handlers attached bypost_scriptstay attached (it runs once, not on every render), a figure withlayout.uirevisionkeeps the user's zoom and pan across re-renders, a render error shows in place andNoneempties the output. Trace data is serialized by plotly's encoder, so numpy arrays, pandas columns and datetimes work as they do infig.to_json().output_plotly()carries the plotly.js and helper dependencies itself, so a page-levelplotly_js()call is no longer needed for it.- Breaking:
@render_plotlyrequiresoutput_plotly(id);ui.output_ui(id)no longer draws it.fig_to_uiis unchanged and remains the path for@render.ui.
plotly.min.jsis served pre-compressed (brotli with the optionalbrotliextra, gzip otherwise) withCache-Control: public, max-age=31536000, immutable,Vary: Accept-Encodingand anETagper encoding, through a route added in front of Shiny's static mount when the first session renders a figure. Compression runs once per process in a background thread.SHINY_PLOTLY_NO_COMPRESS=1turns the route off.shiny-plotly[brotli]extra.- Bench: one warm-up visit per server before measuring, HTTP accounting from the browser's Resource Timing (cache hits count as zero bytes), and rows for plotly.js on the wire and requests that left the cache on a repeat visit.
@render_plotly: ashiny.render.uisubclass that renders aplotly.graph_objects.Figure(or its dict) as HTML drawn withPlotly.newPlot. Works in Core and Express, sync or async, bare or with options;Nonerenders nothing.output_plotly(id, width=, height=): fill-aware placeholder, a drop-in foroutput_widget. No height fills the container from a 400px basis; an explicit height is fixed and opts out of filling.fig_to_ui(...): the lower-level fragment builder for use from@render.uior any htmltools context.plotly_js():HTMLDependencyserving plotly.js straight from the installedplotlywheel at/lib/plotly-<version>/plotly.min.js; every figure carries it, so a page-level call is optional.shiny_plotly_js()and theshiny-plotly.jshelper: aResizeObserverkeeps each graph sized to its container when the container changes without a window resize, and purges a graph once Shiny replaces the output holding it.figurewidget_margins=Truefills in unset margin sides with the values shinywidgets applies to aFigureWidget(l16/t32/r16/b16), for pixel-identical migrations.config(merged over{"responsive": True}) andpost_script(with{plot_id}substitution) options for forwarding plotly events to Shiny inputs.- Test suite: unit tests, in-process Shiny end-to-end tests over a real websocket, and a headless Chromium suite covering sizing, resize tracking, purge on re-render, full screen and
post_scriptwiring.