Summary
Downstream renderer packages (e.g. shinyplotly, shinyshadcn, shinymui) need both a Python side (@render_plotly, Spec/Element builders, HTMLDependency) and a JS side (React components registered via window.shinyreact.registerComponents). This issue settles how those packages should be structured, built, distributed, and consumed.
Establishes the shape; the per-framework rollout in #32 then follows it.
Design decisions
1. Audience: serve both populations equally
A shinyplotly-style package must work for:
- Pure-Python users —
pip install shinyplotly, write @render_plotly in app.py, never touch npm. The JS comes along for the ride via HTMLDependency.
- SPA/Vite users —
npm install @shinyreact/plotly, import { Plotly } from \"@shinyreact/plotly\" in their JSX, and still use @render_plotly server-side.
Neither audience should have to do the other's setup.
2. Repo layout: one source, two publish targets
Single repository per package family with the same shape this repo already uses:
shinyplotly/
├── js/ # TS/React source
│ ├── src/
│ ├── dist/ # Built artifacts (committed)
│ ├── package.json # npm publish source
│ └── vite.config.{iife,esm}.ts
├── pkg-py/
│ └── src/shinyplotly/
│ ├── __init__.py # render_plotly, dep(), Spec helpers
│ └── www/ # IIFE bundle copied from js/dist (committed)
├── pkg-r/ # optional, future
└── pyproject.toml
CI builds two artifacts from js/src/:
- An IIFE bundle copied to
pkg-py/src/shinyplotly/www/ and bundled into the Python wheel. Loaded in pure-Python apps via the package's HTMLDependency.
- An ESM package with TypeScript types, published to npm as
@shinyreact/<name>. Imported directly by SPA/Vite users.
Single source of truth, single version number, two distribution channels. Mirrors what shinyreact itself already does (js/ + pkg-py/), just adds an npm publish step.
3. Dependency registration: ui_output(id, extra_deps=[...]) — not on reactive_output
The Python HTMLDependency from a downstream package is passed via ui_output, not via the renderer. reactive_output (the unified decorator) no longer carries extra_deps:
from shiny import App
from shinyreact import SpaApp
from shinyplotly import render_plotly, dep as plotly_dep
def server(inputs, output, session):
@render_plotly
def my_plot():
return px.scatter(df, x=\"x\", y=\"y\")
app = SpaApp(server, extra_deps=[plotly_dep()])
Rejected alternatives:
- Auto-sniff via mock session. Run
server() once with a fake session at SpaApp init to observe which renderers were declared, harvest their extra_deps. Rejected: real server() functions have side effects (DB pools, observers, async tasks); running them twice is fragile and surprising.
- Import-time side-effect registration.
import shinyplotly registers its dep into a shinyreact._registry; SpaApp reads the registry. Rejected as too magic — globals that change behavior based on import order are a debugging trap.
- Renderer-list constructor arg.
SpaApp(server, renderers=[render_plotly]). Rejected: just shifts the verbosity without the explicitness payoff that extra_deps=[...] gives.
Manual is more verbose but predictable. We can revisit if the friction proves real.
4. Double-load handling: JS-side idempotence
When an SPA/Vite user installs both the npm package and the Python package, the same JS would otherwise load twice — once from the user's Vite bundle, once from the HTMLDependency-served IIFE. That causes duplicate React component registrations and possibly duplicate copies of heavyweight deps (e.g. plotly.js).
Solution: every published bundle (both IIFE and ESM) uses a tiny shared idempotence pattern:
// pseudocode shared across all downstream packages
const REGISTRY = (window.shinyreact.registered ??= new Set());
if (REGISTRY.has(\"@shinyreact/plotly\")) {
// already loaded — short-circuit
} else {
REGISTRY.add(\"@shinyreact/plotly\");
window.shinyreact.registerComponents(catalog, registry);
// …rest of one-time setup
}
User can pass extra_deps=[plotly_dep()] redundantly with no harm. The runtime is the single source of truth.
Rejected alternatives:
- "User's responsibility" / docs only. Footgun — failure mode is silent (duplicate registrations) or obscure (two plotly copies fighting over a
<canvas>). Not acceptable.
SpaApp(suppress_deps=[\"@shinyreact/plotly\"]) flag. Just (A) with extra ceremony.
This pattern should live in a tiny shared helper (probably exported from @shinyreact/core or window.shinyreact itself) so each downstream package writes one line, not the boilerplate above.
What this enables
Open questions
- Where does the shared idempotence helper live? Three options:
@shinyreact/core (clean, but creates an npm dependency for the IIFE case where externalizing isn't natural), inline in each package (~20 LOC duplication), or attached to window.shinyreact at runtime (no build-time dependency, but ad-hoc API surface). Lean toward window.shinyreact.registerOnce(name, fn) — already at runtime, no extra packages.
- Versioning across npm + PyPI. Same version number for both? Or independent semver? Recommend same version, single git tag drives both publishes.
pkg-r/ for downstream packages. Out of scope for v1, but the layout reserves space.
- Build tooling: Vite (matches what we already use) vs. tsup vs. unbuild. Probably Vite for parity with the rest of the repo.
- Template repo: do we ship
posit-dev/shinyreact-binding-template so external authors can clone and go? Likely yes once the first 1–2 in-house packages prove the shape.
- Component naming collisions across packages.
@shinyreact/shadcn and @shinyreact/mui both want a Button. The registerComponents registry is global. Either namespace at registration (shadcn:Button, mui:Button) or last-write-wins with a console warning. Lean toward namespacing.
- Treeshaking the ESM build. SPA users importing one component shouldn't pull the whole package. Standard ESM hygiene; call it out in the template.
Relationship to other issues
Out of scope
- R package equivalents.
- Frameworks other than React (Vue/Svelte/Solid).
- A monorepo umbrella (
@shinyreact/bindings) shipping all official bindings under one tree — start with one repo per package, revisit if maintenance pressure makes a monorepo attractive.
Summary
Downstream renderer packages (e.g.
shinyplotly,shinyshadcn,shinymui) need both a Python side (@render_plotly, Spec/Element builders,HTMLDependency) and a JS side (React components registered viawindow.shinyreact.registerComponents). This issue settles how those packages should be structured, built, distributed, and consumed.Establishes the shape; the per-framework rollout in #32 then follows it.
Design decisions
1. Audience: serve both populations equally
A
shinyplotly-style package must work for:pip install shinyplotly, write@render_plotlyinapp.py, never touchnpm. The JS comes along for the ride viaHTMLDependency.npm install @shinyreact/plotly,import { Plotly } from \"@shinyreact/plotly\"in their JSX, and still use@render_plotlyserver-side.Neither audience should have to do the other's setup.
2. Repo layout: one source, two publish targets
Single repository per package family with the same shape this repo already uses:
CI builds two artifacts from
js/src/:pkg-py/src/shinyplotly/www/and bundled into the Python wheel. Loaded in pure-Python apps via the package'sHTMLDependency.@shinyreact/<name>. Imported directly by SPA/Vite users.Single source of truth, single version number, two distribution channels. Mirrors what
shinyreactitself already does (js/+pkg-py/), just adds annpm publishstep.3. Dependency registration:
ui_output(id, extra_deps=[...])— not onreactive_outputThe Python
HTMLDependencyfrom a downstream package is passed viaui_output, not via the renderer.reactive_output(the unified decorator) no longer carriesextra_deps:Rejected alternatives:
server()once with a fake session atSpaAppinit to observe which renderers were declared, harvest theirextra_deps. Rejected: realserver()functions have side effects (DB pools, observers, async tasks); running them twice is fragile and surprising.import shinyplotlyregisters its dep into ashinyreact._registry;SpaAppreads the registry. Rejected as too magic — globals that change behavior based on import order are a debugging trap.SpaApp(server, renderers=[render_plotly]). Rejected: just shifts the verbosity without the explicitness payoff thatextra_deps=[...]gives.Manual is more verbose but predictable. We can revisit if the friction proves real.
4. Double-load handling: JS-side idempotence
When an SPA/Vite user installs both the npm package and the Python package, the same JS would otherwise load twice — once from the user's Vite bundle, once from the
HTMLDependency-served IIFE. That causes duplicate React component registrations and possibly duplicate copies of heavyweight deps (e.g.plotly.js).Solution: every published bundle (both IIFE and ESM) uses a tiny shared idempotence pattern:
User can pass
extra_deps=[plotly_dep()]redundantly with no harm. The runtime is the single source of truth.Rejected alternatives:
<canvas>). Not acceptable.SpaApp(suppress_deps=[\"@shinyreact/plotly\"])flag. Just (A) with extra ceremony.This pattern should live in a tiny shared helper (probably exported from
@shinyreact/coreorwindow.shinyreactitself) so each downstream package writes one line, not the boilerplate above.What this enables
shinyplotlypackage can be authored once and consumed both ways.Open questions
@shinyreact/core(clean, but creates an npm dependency for the IIFE case where externalizing isn't natural), inline in each package (~20 LOC duplication), or attached towindow.shinyreactat runtime (no build-time dependency, but ad-hoc API surface). Lean towardwindow.shinyreact.registerOnce(name, fn)— already at runtime, no extra packages.pkg-r/for downstream packages. Out of scope for v1, but the layout reserves space.posit-dev/shinyreact-binding-templateso external authors can clone and go? Likely yes once the first 1–2 in-house packages prove the shape.@shinyreact/shadcnand@shinyreact/muiboth want aButton. TheregisterComponentsregistry is global. Either namespace at registration (shadcn:Button,mui:Button) or last-write-wins with a console warning. Lean toward namespacing.Relationship to other issues
@shinyreact/coreor vendor the IIFE.shadcn,mui,bootstrap, …) follow this template.Out of scope
@shinyreact/bindings) shipping all official bindings under one tree — start with one repo per package, revisit if maintenance pressure makes a monorepo attractive.