Skip to content

Packaging strategy for downstream renderer packages (Python + JS) #33

Description

@schloerke

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 userspip install shinyplotly, write @render_plotly in app.py, never touch npm. The JS comes along for the ride via HTMLDependency.
  • SPA/Vite usersnpm 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions