Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`shinyreact` is a monorepo providing React UI infrastructure for Shiny (Python and R). It provides zero UI components — it is the bridge between a Shiny server that contains only reactive computation and a React client the app author owns.

The repo ships one first-class pattern: the **`ui.tsx` pattern** — `set_react_page()` (Python Express) / `page_react_html()` (Python Core, R) bootstraps a static `www/index.html` hosting a React client whose entry conventionally lives in `ui.tsx`; the client and server communicate through the `useShinyInput` / `useShinyOutputValue` hook family. See `DESIGN.md` for background.
The repo ships one first-class pattern: the **`ui.tsx` pattern** — `set_react_page()` (Python Express) / `page_react()` (Python Core, R) bootstraps a React client whose entry conventionally lives in `ui.tsx` (compiled to `www/ui.js`, discovered automatically); the client and server communicate through the `useShinyInput` / `useShinyOutputValue` hook family. See `DESIGN.md` for background.

## Terminology

**`ui.tsx`** is the canonical name of the pattern. **Never write "SPA", "Single Page App", "Single-Page Application", "traditional pattern", `client-ui`, or `ui-object`** in new content (docs, comments, commit messages, PR/issue text).

- **`ui.tsx` pattern** — UI defined in a client-side codebase whose entry is conventionally `ui.tsx` (or `App.jsx`, or `app.js` for no-build); bootstrapped from the app file via `set_react_page()` / `page_react_html()`. `ui.tsx` is the *idiomatic* canonical name — examples may use simpler variants like `www/app.js` (no-build) or `src/App.jsx` (Vite + JSX). Treat `ui.tsx` as a *role label* for the React entry, not a strict filename requirement.
- **`ui.tsx` pattern** — UI defined in a client-side codebase whose entry is conventionally `ui.tsx` (or `ui.jsx`, or `ui.js` for no-build); bootstrapped from the app file via `set_react_page()` / `page_react()` (or `page_react_html()` for apps that own a full HTML document). `ui.tsx` is the *idiomatic* canonical name — examples use the same role at different tiers: `www/ui.js` (no-build) or `src/ui.jsx` (Vite + JSX, built to `www/ui.js`). Treat `ui.tsx` as a *role label* for the React entry, not a strict filename requirement.
- The phrase "traditional Shiny" is fine when it refers to vanilla Shiny (no shinyreact involved).
- The repo formerly also shipped an **`app.py` pattern** (server-side JSON-spec rendering via `Node` / `render_react` / `page_react`). It was removed in #168; see the tracking comment on #167 for git-history pointers if you encounter stale references.

Expand Down Expand Up @@ -91,17 +91,19 @@ The JS output (`pkg-js/dist/shinyreact.js`) is a self-contained IIFE that bundle

- `@shinyreact.reactive_output` — `Renderer[Jsonifiable]` subclass; passes raw JSON data through for `useShinyOutputValue()` hooks, with no placeholder (`auto_output_ui()` returns `None`)
- `shinyreact.send_message(session, type, data)` — sends `shinyReactMessage` custom messages consumed by `useShinyMessageHandler()`
- `shinyreact.set_react_page(path="www/index.html")` — Express helper that serves a static `www/index.html`; auto-discovers `HTMLDependency` objects from traditional Shiny renderers and injects the shinyreact dep
- `shinyreact.page_react_html(path="www/index.html")` — Core-mode helper that serves a static `www/index.html` as the `ui` argument of `App(ui=..., server=...)`; attaches the shinyreact dep. The Core counterpart to the Express-only `set_react_page()`. Unlike `set_react_page`, it does not auto-discover renderer dependencies
- `shinyreact.set_react_page(path=None)` — Express helper; with no args serves `www/index.html` when present, else discovers `www/ui.js` / `www/ui.css` and emits no body HTML. Auto-discovers `HTMLDependency` objects from traditional Shiny renderers and injects the shinyreact dep
- `shinyreact.page_react(src_dir=None, js_file="ui.js", css_file="ui.css", title=None)` — Core-mode zero-config page: discovers `www/ui.js` / `www/ui.css` next to the calling module, serves them as an mtime-versioned dependency (cache-busted), title defaults to the app folder name; the client appends its own mount container to `<body>`
- `shinyreact.page_react_html(path="www/index.html")` — Core-mode helper that serves an HTML file as the `ui` argument of `App(ui=..., server=...)`; attaches the shinyreact dep. In Python the file is a body *fragment* (full-document support is blocked on an upstream py-shiny gap); in R it must be a complete document with a `{{ headContent() }}` marker (htmltools `htmlTemplate()`). Neither auto-discovers renderer dependencies
- `shinyreact.page_bare(...)` / `shinyreact.page_react_dep(...)` — escape-hatch page builder and app-bundle `HTMLDependency` helper
- Bookmark restore + protocol handshake: page entry points emit a `<script type="application/json" id="shinyreact-config">` tag carrying the wire-protocol version and any restored input values (`_bookmark.py` / `bookmark.R`); the bundle asserts the protocol major version and seeds `useShinyInput` initial values from it; the config tag is the only delivery channel (`window.shinyreact._restore` is a write-only DevTools sentinel)

### R package

The R package (`pkg-r/`) mirrors the Python API in R idioms; exports are `reactive_output`, `page_bare`, `page_react_html`, `page_react_dep`, `send_message`. Key shape differences from Python:
The R package (`pkg-r/`) mirrors the Python API in R idioms; exports are `reactive_output`, `page_bare`, `page_react`, `page_react_html`, `page_react_dep`, `send_message`. Key shape differences from Python:

- `reactive_output(expr, ...)` is a **function** assigned to `output$id`, not a decorator/`Renderer` class.
- `page_react_html(path = "www/index.html")` matches Python's `page_react_html()`; Python additionally has the Express-only `set_react_page()`.
- `page_react(src_dir = "www", ...)` matches Python's `page_react()` (R resolves against the working directory; Python against the calling module).
- `page_react_html(path = "www/index.html")` requires a complete HTML document with a `{{ headContent() }}` marker — unlike Python's, which still takes a body fragment (upstream py-shiny gap; see the `page_react_html` docstring). Python additionally has the Express-only `set_react_page()`.
- `send_message(session, type, data)` matches Python.
- `page_react_dep()` takes `src_dir` as a required first argument; Python's is keyword-only and infers `src_dir`/`name` from the caller's `__file__` when omitted (R has no equivalent). Pass `src_dir=` explicitly in Python too if you wrap the call in a helper — the inference reads the *immediate* calling frame.

Expand Down
8 changes: 4 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,13 @@ A Shiny app under the ui.tsx-first model consists of two parts:
my-app/
app.py # Server logic: reactive computations, data access, business logic
package.json # JS dependencies (AI-generated)
index.html # Entry point for the React app (AI-generated)
src/ # Client source (AI-generated)
ui.jsx # Entry point for the React app
App.jsx
styles.css
dist/ # Built bundle (generated by build step, served by Shiny)
app.js
styles.css
www/ # Built bundle (generated by build step, served by Shiny)
ui.js
ui.css
```

The author owns `app.py`. Everything else is generated by AI (Claude or equivalent), typically via a Claude Skill or similar tooling that understands Shiny's client-server bridge. The `package.json` declares dependencies on the Shiny client runtime (`@posit/shiny`), the bridge hooks (`@posit/shinyreact`), React, and any component libraries. The build step (managed by `shiny run` or equivalent) resolves dependencies and bundles `src/` into `dist/`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This repo ships per-language packages:

## How it works

`shinyreact` implements the **`ui.tsx` pattern**: UI defined in a client codebase whose entry conventionally lives in `ui.tsx` (or `App.jsx`, or `app.js` for no-build):
`shinyreact` implements the **`ui.tsx` pattern**: UI defined in a client codebase whose entry conventionally lives in `ui.tsx` (or `ui.jsx`, or `ui.js` for no-build):

1. The Shiny server contains only reactive computation; it bootstraps a static page — `set_react_page()` (Express) or `page_react_html()` (Core) in Python, `page_react_html()` in R
2. A static `www/index.html` plus your React client serve the UI
Expand Down
28 changes: 14 additions & 14 deletions examples/01-hello/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
Shiny's canonical [`01_hello`](https://github.com/rstudio/shiny/blob/main/inst/examples-shiny/01_hello/app.R)
app — a bins slider over the Old Faithful waiting times — rebuilt as the
smallest possible `ui.tsx`-first app. No JSX, no bundler, no `package.json`.
Edit `app.js` and reload.
Edit `ui.js` and reload.

`app.py` (Express, via `set_react_page()`) and `app-core.py` (Core, via
`page_react_html()`) are two server-side entries for the same `www/` client;
`page_react()`) are two server-side entries for the same `www/` client;
`app.R` is the R twin.

## What it shows
Expand All @@ -20,7 +20,7 @@ produces a picture:
from R's `hist(..., plot = FALSE)` / a dependency-free Python binner), plus a
caption string. That's the entire server. No plotting library, no image
encoding, no `plotOutput` placeholder.
- **Client** — `www/app.js` reads that JSON with `useShinyOutputValue` and draws
- **Client** — `www/ui.js` reads that JSON with `useShinyOutputValue` and draws
the bars as SVG `<rect>`s. Because the chart is real DOM the client owns, it
can be styled, animated, or made interactive without another round trip.

Expand All @@ -37,14 +37,13 @@ never tears the SVG down and re-mounts it.
```
examples/01-hello/
├── app.py # Express: set_react_page() + 2 reactive_output outputs
├── app-core.py # Core: page_react_html() + App(..., static_assets=), same outputs
├── app.R # R: page_react_html() + reactive_output, same outputs
├── app-core.py # Core: page_react() + App(app_ui, server), same outputs
├── app.R # R: page_react() + reactive_output, same outputs
├── faithful.py # Old Faithful waiting times + a stdlib-only binner (Python)
├── faithful.csv # base R's `faithful` dataset, exported for the Python servers
└── www/
├── index.html # 2 lines: stylesheet, script (the app appends its own mount div to <body>)
├── app.js # raw React.createElement (with `h` shorthand) + an SVG histogram
└── main.css # sidebar/panel layout
├── ui.js # raw React.createElement (with `h` shorthand) + an SVG histogram
└── ui.css # sidebar/panel layout
```

No `node_modules`, no Vite, no build script — and on the Python side no
Expand All @@ -56,14 +55,15 @@ return `NULL` until the client's first `bins` message arrives (Python raises a
silent exception instead), and wrap the histogram vectors in `I()` so a
single-bin result still serializes as a JSON array rather than a scalar.

`app-core.py` passes `static_assets={"/": .../www}` to `App()`. Shiny Express
(`app.py`) and R's `runApp()` (`app.R`) both mount the app directory's `www/`
automatically; Core's `App()` does not, so without it `index.html` loads and
then 404s on `app.js` and `main.css`.
There is no `index.html`: every server calls a zero-argument page function
(`set_react_page()` / `page_react()`) that discovers `www/ui.js` and
`www/ui.css` and serves them as an mtime-versioned dependency — so edits are
never stale in the browser cache, and Core's `App()` needs no
`static_assets=` mount. The client appends its own container to `<body>`.

## Bridge primitives used

- `from shinyreact import reactive_output, set_react_page` (Express server, `app.py`) / `page_react_html` (Core server, `app-core.py`); `library(shinyreact)` with `page_react_html()` + `reactive_output()` in `app.R`
- `from shinyreact import reactive_output, set_react_page` (Express server, `app.py`) / `page_react` (Core server, `app-core.py`); `library(shinyreact)` with `page_react()` + `reactive_output()` in `app.R`
- `window.shinyreact.useShinyInput(id, default)` for the bins slider
- `window.shinyreact.useShinyOutputValue(id, default)` for the histogram data and caption
- `window.shinyreact.useShinyOutputStatus(id)` to dim the chart while it recalculates
Expand All @@ -80,7 +80,7 @@ uv run shiny run examples/01-hello/app.py
# Core API (same client, same outputs)
uv run shiny run examples/01-hello/app-core.py

# R (same client, same outputs — the R package's page_react_html + reactive_output)
# R (same client, same outputs — the R package's page_react + reactive_output)
Rscript -e 'shiny::runApp("examples/01-hello/app.R")'
```

Expand Down
13 changes: 5 additions & 8 deletions examples/01-hello/app-core.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from pathlib import Path

from faithful import histogram, waiting
from shiny import App, Inputs, Outputs, Session
from shinyreact import page_react_html, reactive_output
from shinyreact import page_react, reactive_output

app_ui = page_react_html() # serves www/index.html (Core API)
app_ui = page_react() # discovers www/ui.js + www/ui.css (Core API)


def server(input: Inputs, output: Outputs, session: Session):
Expand All @@ -18,7 +16,6 @@ def dist_caption():
return f"{len(waiting)} eruptions in {n} bin{'' if n == 1 else 's'}"


# Core apps must mount www/ themselves. Shiny Express auto-serves the app
# directory's www/ at "/", but App() does not — without this, index.html loads
# and then 404s on app.js and main.css.
app = App(app_ui, server, static_assets={"/": Path(__file__).parent / "www"})
# page_react() serves ui.js/ui.css itself (an mtime-versioned dependency), so
# no static_assets mount is needed for them.
app = App(app_ui, server)
2 changes: 1 addition & 1 deletion examples/01-hello/app.R
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ library(shinyreact)
# from the faithful.csv exported next to this file.
waiting <- faithful$waiting

ui <- page_react_html("www/index.html")
ui <- page_react() # discovers www/ui.js + www/ui.css

server <- function(input, output, session) {
# input$bins is NULL until the client's first useShinyInput("bins", 30)
Expand Down
2 changes: 0 additions & 2 deletions examples/01-hello/www/index.html

This file was deleted.

File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ function App() {
);
}

// No mount div in index.html — create the container and append it to <body>.
// No mount div in the generated page — create the container and append it to <body>.
// The script is deferred, so document.body is parsed by the time this runs.
const root = ReactDOM.createRoot(
document.body.appendChild(document.createElement("div")),
Expand Down
5 changes: 2 additions & 3 deletions examples/02-columns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ The result: ~20 lines of server logic, versus ~80 lines wrestling with observers
examples/02-columns/
├── app.py # set_react_page() + 1 reactive_output (column_data) + 1 reactive.effect on input.move_item
└── www/
├── index.html
├── app.js # raw React.createElement (Column + ItemRow components)
└── main.css
├── ui.js # raw React.createElement (Column + ItemRow components)
└── ui.css
```

Same no-build shape as [01-hello](../01-hello/). No `package.json`, no bundler.
Expand Down
3 changes: 0 additions & 3 deletions examples/02-columns/www/index.html

This file was deleted.

File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,9 @@ function App() {
);
}

const root = ReactDOM.createRoot(document.getElementById("root"));
// No mount div in the generated page -- create the container and append it
// to <body>. The script is deferred, so document.body is parsed by now.
const root = ReactDOM.createRoot(
document.body.appendChild(document.createElement("div")),
);
root.render(h(App));
4 changes: 2 additions & 2 deletions examples/03-columns-shadcn/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/
package-lock.json
www/app.js
www/style.css
www/ui.js
www/ui.css
9 changes: 4 additions & 5 deletions examples/03-columns-shadcn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,22 @@ examples/03-columns-shadcn/
├── vite.config.js # lib-mode IIFE; React → window.shinyreact
├── src/
│ ├── App.jsx # composes Column → ItemRow with shadcn Card/Button
│ ├── main.jsx # mounts via window.shinyreact.React/ReactDOM
│ ├── ui.jsx # mounts via window.shinyreact.React/ReactDOM (appends its own container)
│ ├── index.css # Tailwind v4 + shadcn theme tokens
│ ├── lib/utils.js # cn() = clsx + tailwind-merge
│ └── components/ui/
│ ├── button.jsx # actual shadcn Button (cva variants)
│ └── card.jsx # actual shadcn Card stack
└── www/
├── index.html # 3 lines, committed
├── app.js # built by Vite (gitignored)
└── style.css # built by Vite (gitignored)
├── ui.js # built by Vite (gitignored)
└── ui.css # built by Vite (gitignored)
```

## Build plumbing

Worth understanding because every shadcn-style example in this repo uses the same setup:

- `vite.config.js` is in **lib mode** with `format: "iife"`, output filename `app.js`. We can't use Vite's regular HTML pipeline because we need a single self-contained bundle that reuses the page's existing React.
- `vite.config.js` is in **lib mode** with `format: "iife"`, output filename `ui.js` — the name `set_react_page()` discovers, so the app file needs no arguments and no `index.html`. We can't use Vite's regular HTML pipeline because we need a single self-contained bundle that reuses the page's existing React.
- `react`, `react-dom`, `react-dom/client` are listed as `external` and mapped via `rollupOptions.output.globals` to `window.shinyreact.React` / `window.shinyreact.ReactDOM`. The IIFE bundle reuses the React instance that owns the shinyreact hooks (mixing two React copies would break the hooks).
- `@tailwindcss/vite` wires Tailwind v4 in directly; the shadcn design tokens live in `src/index.css`.
- `define: { "process.env.NODE_ENV": '"production"' }` is set because lib mode does not auto-replace it (it assumes a downstream bundler will). Without it the bundled React jsx-runtime hits a `process is not defined` error in the browser.
Expand Down
8 changes: 0 additions & 8 deletions examples/03-columns-shadcn/src/main.jsx

This file was deleted.

12 changes: 12 additions & 0 deletions examples/03-columns-shadcn/src/ui.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import "@/index.css";

import App from "@/App";

const { React, ReactDOM } = window.shinyreact;

// No index.html in this example -- the server page is generated by
// set_react_page() discovery, so the app appends its own mount container.
const root = ReactDOM.createRoot(
document.body.appendChild(document.createElement("div")),
);
root.render(React.createElement(App));
6 changes: 4 additions & 2 deletions examples/03-columns-shadcn/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ export default defineConfig({
emptyOutDir: false,
cssCodeSplit: false,
lib: {
entry: path.resolve(__dirname, "src/main.jsx"),
entry: path.resolve(__dirname, "src/ui.jsx"),
formats: ["iife"],
name: "ColumnsShadcn",
fileName: () => "app.js",
fileName: () => "ui.js",
},
rollupOptions: {
external: ["react", "react-dom", "react-dom/client"],
output: {
// Name the emitted CSS asset ui.css (Vite 5 lib mode defaults to style.css).
assetFileNames: "ui.[ext]",
globals: {
react: "window.shinyreact.React",
"react-dom": "window.shinyreact.ReactDOM",
Expand Down
3 changes: 0 additions & 3 deletions examples/03-columns-shadcn/www/index.html

This file was deleted.

4 changes: 2 additions & 2 deletions examples/04-shadcn/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/
package-lock.json
www/app.js
www/style.css
www/ui.js
www/ui.css
Loading
Loading