diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e36c4c1..a3a6bee 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -38,7 +38,8 @@ "source": "./", "strict": false, "skills": [ - "./brand-yml" + "./brand-yml", + "./shiny/shiny-react" ] }, { diff --git a/README.md b/README.md index 6e3a9a4..9e282cf 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ R package development skills for working with the r-lib ecosystem and modern R p Skills for Shiny app development in both R and Python. - **[brand-yml](./brand-yml/)** - Create and apply brand.yml files for consistent styling across Shiny apps, with support for bslib (R) and ui.Theme (Python), including automatic brand discovery and theming functions for plots and tables +- **[shiny-react](./shiny/shiny-react/)** - Build Shiny applications with React frontends using the @posit/shiny-react library, with hooks for bidirectional communication (useShinyInput, useShinyOutput), shadcn/ui integration, and support for both R and Python backends ### Quarto diff --git a/shiny/README.md b/shiny/README.md index ba39ea4..fd5d0bb 100644 --- a/shiny/README.md +++ b/shiny/README.md @@ -26,6 +26,29 @@ Create and use `_brand.yml` files for consistent branding across Shiny applicati - [Shiny for Python brand.yml docs](https://shiny.posit.co/py/api/core/ui.Theme.html#shiny.ui.Theme.from_brand) - [Quarto brand.yml docs](https://quarto.org/docs/authoring/brand.html) +### `shiny-react` + +Build Shiny applications with React frontends using the `@posit/shiny-react` library. Use when creating modern, component-based UIs with React while leveraging Shiny's reactive backend (R or Python). + +**Organization**: Main skill file covers quick start and essential patterns. Reference files provide deep dives: +- `typescript-api.md` - Complete TypeScript API for hooks and components +- `r-backend.md` - R Shiny backend patterns with render_json and post_message +- `python-backend.md` - Python Shiny backend patterns +- `shadcn-setup.md` - shadcn/ui and Tailwind CSS integration guide +- `internals.md` - How shiny-react works under the hood (registries, bindings) + +**Key Features**: +- `useShinyInput` / `useShinyOutput` hooks for bidirectional communication +- `useShinyMessageHandler` for server-to-client messages +- `ImageOutput` component for Shiny plots +- shadcn/ui integration with Tailwind CSS +- Support for both R and Python Shiny backends + +**Resources**: +- [shiny-react GitHub](https://github.com/wch/shiny-react) +- [create-shiny-react-app](https://www.npmjs.com/package/create-shiny-react-app) +- [shadcn/ui](https://ui.shadcn.com/) + ## Potential Skills This category could include skills for: diff --git a/shiny/shiny-react/SKILL.md b/shiny/shiny-react/SKILL.md new file mode 100644 index 0000000..457e73b --- /dev/null +++ b/shiny/shiny-react/SKILL.md @@ -0,0 +1,296 @@ +--- +name: shiny-react +description: > + Build Shiny applications with React frontends using the @posit/shiny-react library. + Use when: (1) Creating new Shiny apps with React UI, (2) Adding React components to + existing Shiny apps, (3) Using shadcn/ui or other React component libraries with Shiny, + (4) Understanding useShinyInput/useShinyOutput hooks, (5) Setting up bidirectional + communication between React and R/Python Shiny backends, (6) Building modern data + dashboards with React and Shiny. Supports both R and Python Shiny backends. +--- + +# shiny-react + +Build Shiny applications with React frontends. The `@posit/shiny-react` library provides React hooks for bidirectional communication between React components and Shiny servers (R or Python). + +## Quick Start + +Create a new app: + +```bash +npx create-shiny-react-app myapp +cd myapp +npm install +npm run dev # Builds frontend and starts Shiny app on port 8000 +``` + +## Core Concepts + +### Data Flow + +Communication is bidirectional: +- **React → Shiny**: Use `useShinyInput` to send values to the server (appears as `input$id` in R or `input.id()` in Python) +- **Shiny → React**: Use `useShinyOutput` to receive reactive values from server outputs (`output$id`) + +``` +React Component ──[useShinyInput]──> Shiny Server (R/Python) + │ + Process Data + │ +React Component <──[useShinyOutput]── Shiny Server +``` + +### TypeScript Hooks + +```typescript +import { useShinyInput, useShinyOutput } from "@posit/shiny-react"; + +function MyComponent() { + // Send data TO Shiny (like input$my_input in R) + const [value, setValue] = useShinyInput("my_input", "default"); + + // Receive data FROM Shiny (from output$my_output) + const [result, recalculating] = useShinyOutput("my_output", undefined); + + return ( +
+ setValue(e.target.value)} /> +
{recalculating ? "Loading..." : result}
+
+ ); +} +``` + +### Backend (R) + +```r +library(shiny) +source("shinyreact.R", local = TRUE) + +server <- function(input, output, session) { + output$my_output <- render_json({ + toupper(input$my_input) + }) +} + +shinyApp(ui = page_react(title = "My App"), server = server) +``` + +### Backend (Python) + +```python +from shiny import App, Inputs, Outputs, Session +from shinyreact import page_react, render_json + +def server(input: Inputs, output: Outputs, session: Session): + @render_json + def my_output(): + return input.my_input().upper() + +app = App(page_react(title="My App"), server) +``` + +## Writing React Components for Shiny + +When writing React components that communicate with Shiny: + +1. **Use `useShinyInput` for any value that needs to reach the server** - This replaces direct state when the server needs to react to changes. + +2. **Use `useShinyOutput` for any data coming from the server** - Always handle the `undefined` initial state and the `recalculating` boolean for loading states. + +3. **Match IDs exactly** - The string ID in `useShinyInput("foo", ...)` must match `input$foo` (R) or `input.foo()` (Python) exactly. + +4. **Choose appropriate debounce values**: + - Text inputs: 100-300ms (default is 100ms) + - Sliders/continuous: 50-100ms + - Buttons: Use `priority: "event"` with no debounce + - Expensive server operations: 500ms+ + +5. **Button clicks need event priority** to ensure each click triggers the server: + ```typescript + const [clicks, setClicks] = useShinyInput("btn", 0, { priority: "event" }); + + ``` + +6. **Handle loading states** - The second return value from `useShinyOutput` indicates recalculation: + ```typescript + const [data, isLoading] = useShinyOutput("result", undefined); + if (isLoading) return ; + ``` + +## Decision Tree + +1. **New app from scratch?** → Use `npx create-shiny-react-app` +2. **Need TypeScript API details?** → Read `references/typescript-api.md` +3. **Setting up R backend?** → Read `references/r-backend.md` +4. **Setting up Python backend?** → Read `references/python-backend.md` +5. **Using shadcn/ui or Tailwind?** → Read `references/shadcn-setup.md` +6. **Understanding internals?** → Read `references/internals.md` + +## Project Structure + +Standard shiny-react project layout: + +``` +myapp/ +├── package.json # npm dependencies and scripts +├── tsconfig.json # TypeScript configuration +├── srcts/ # React TypeScript source +│ ├── main.tsx # Entry point (renders to #root) +│ ├── App.tsx # Main React component +│ └── styles.css # CSS styles +├── r/ # R Shiny backend +│ ├── app.R # Shiny app +│ ├── shinyreact.R # Utility functions (page_react, render_json) +│ └── www/ # Built JS/CSS (auto-generated) +└── py/ # Python Shiny backend + ├── app.py # Shiny app + ├── shinyreact.py # Utility functions + └── www/ # Built JS/CSS (auto-generated) +``` + +## Essential Patterns + +### Input with Debouncing + +```typescript +const [value, setValue] = useShinyInput("search", "", { + debounceMs: 300, // Wait 300ms after typing stops (default: 100) +}); +``` + +### Typed Outputs + +```typescript +interface Stats { mean: number; median: number; max: number; } +const [stats, loading] = useShinyOutput("statistics", undefined); +``` + +### Server-to-Client Messages + +React: +```typescript +useShinyMessageHandler("notification", (msg: { text: string }) => { + showToast(msg.text); +}); +``` + +R: +```r +post_message(session, "notification", list(text = "Data updated!")) +``` + +Python: +```python +await post_message(session, "notification", {"text": "Data updated!"}) +``` + +### Plot/Image Output + +```typescript +import { ImageOutput } from "@posit/shiny-react"; + + +``` + +R backend uses standard `renderPlot()` - the ImageOutput automatically handles sizing. + +### Data Frames (Column-Major JSON) + +Data frames serialize as column arrays: +```json +{"mpg": [21, 21, 22.8], "cyl": [6, 6, 4], "disp": [160, 160, 108]} +``` + +R: +```r +output$table_data <- render_json({ mtcars[1:10, ] }) +``` + +TypeScript: +```typescript +const [data] = useShinyOutput>("table_data", undefined); +``` + +## Build System + +Uses esbuild for fast bundling. Key scripts in package.json: + +```json +{ + "scripts": { + "dev": "concurrently \"npm run watch\" \"npm run shinyapp\"", + "build": "esbuild srcts/main.tsx --bundle --minify --outfile=r/www/main.js", + "watch": "esbuild srcts/main.tsx --bundle --outfile=r/www/main.js --watch" + } +} +``` + +Entry point (`srcts/main.tsx`): +```typescript +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +const container = document.getElementById("root"); +if (container) { + createRoot(container).render(); +} +``` + +## Examples & Utilities + +### Official Examples + +The [shiny-react repository](https://github.com/wch/shiny-react) includes example apps in `examples/`: + +| Example | Description | +|---------|-------------| +| `1-hello-world` | Basic bidirectional communication | +| `2-inputs` | Various input types (text, number, checkbox, slider, etc.) | +| `3-outputs` | JSON data and plot outputs | +| `4-messages` | Server-to-client messages with toast notifications | +| `5-shadcn` | Modern UI with shadcn/ui and Tailwind CSS | +| `6-dashboard` | Full analytics dashboard with charts and tables | +| `7-chat` | AI chat app with streaming responses | + +Each example includes complete R and Python backends. + +### Utility Files (shinyreact.R / shinyreact.py) + +Each shiny-react app requires utility files that provide `page_react()`, `render_json()`, and `post_message()`. These are **not installed as packages** - copy them, or just the functions that you need, into your project or package. + +**Ready-to-use utility files are included in this skill:** +- `assets/shinyreact.R` - For R apps or packages +- `assets/shinyreact.py` - For Python apps or packages + +The utilities are documented in `references/r-backend.md` and `references/python-backend.md`. + +## Common Issues + +**Hooks not working**: Ensure `page_react()` is used in the UI - it includes the `
` element. + +**Values not updating**: Check that input/output IDs match exactly between React and R/Python. + +**TypeScript errors**: Install types: `npm install -D @types/react @types/react-dom` + +**Build output location**: esbuild outputs to `r/www/` or `py/www/` - ensure paths match in package.json scripts. + +**Python: Mutable objects not triggering updates**: Python Shiny uses object identity (not equality) for reactivity. Copy mutable objects before modifying: +```python +# Wrong - same object identity, no update triggered +items.append(new_item) +reactive_value.set(items) + +# Correct - new object identity triggers update +new_items = items[:] # or list(items) +new_items.append(new_item) +reactive_value.set(new_items) +``` + +## Anti-Patterns to Avoid + +- **Don't mix `useState` and `useShinyInput` for the same value** - Use `useShinyInput` if the server needs the value, `useState` for local-only UI state. +- **Don't create circular dependencies** - Avoid patterns where an output triggers an input that triggers the same output. +- **Don't forget loading states** - Always handle `recalculating` from `useShinyOutput` to show users when data is stale. +- **Don't use `useShinyInput` for high-frequency updates without debouncing** - Mouse movements, scroll positions, etc. should have high debounce values or be kept local. diff --git a/shiny/shiny-react/assets/shinyreact.R b/shiny/shiny-react/assets/shinyreact.R new file mode 100644 index 0000000..2c91f8e --- /dev/null +++ b/shiny/shiny-react/assets/shinyreact.R @@ -0,0 +1,91 @@ +# shiny-react utility functions for R +# Copy this file to your project's r/ directory +# +# Provides: +# page_react() - Creates HTML page with React mounting point +# render_json() - Renders arbitrary JSON data to React +# post_message() - Sends custom messages to React components +# +# License: MIT 2025, Posit Software, PBC +# Source: https://github.com/wch/create-shiny-react-app/blob/main/templates/2-scaffold/r/shinyreact.R + +library(shiny) + +page_bare <- function(..., title = NULL, lang = NULL) { + ui <- list( + shiny:::jqueryDependency(), + if (!is.null(title)) tags$head(tags$title(title)), + ... + ) + attr(ui, "lang") <- lang + ui +} + +page_react <- function( + ..., + title = NULL, + js_file = "main.js", + css_file = "main.css", + lang = "en" +) { + page_bare( + title = title, + tags$head( + if (!is.null(js_file)) tags$script(src = js_file, type = "module"), + if (!is.null(css_file)) tags$link(href = css_file, rel = "stylesheet") + ), + tags$div(id = "root"), + ... + ) +} + + +#' Reactively render arbitrary JSON object data. +#' +#' This is a generic renderer that can be used to render any Jsonifiable data. +#' The data goes through shiny:::toJSON() before being sent to the client. +render_json <- function( + expr, + env = parent.frame(), + quoted = FALSE, + outputArgs = list(), + sep = " " +) { + func <- installExprFunction( + expr, + "func", + env, + quoted, + label = "render_json" + ) + + createRenderFunction( + func, + function(value, session, name, ...) { + value + }, + function(...) { + stop("Not implemented") + }, + outputArgs + ) +} + +#' Send a custom message to the client +#' +#' A convenience function for sending custom messages from the Shiny server to +#' React components using useShinyMessageHandler() hook. This wraps messages in a +#' standard format and sends them via the "shinyReactMessage" channel. +#' +#' @param session The Shiny session object +#' @param type The message type (should match messageType in useShinyMessageHandler) +#' @param data The data to send to the client +post_message <- function(session, type, data) { + session$sendCustomMessage( + "shinyReactMessage", + list( + type = type, + data = data + ) + ) +} diff --git a/shiny/shiny-react/assets/shinyreact.py b/shiny/shiny-react/assets/shinyreact.py new file mode 100644 index 0000000..ee52b96 --- /dev/null +++ b/shiny/shiny-react/assets/shinyreact.py @@ -0,0 +1,113 @@ +# shiny-react utility functions for Python +# Copy this file to your project's py/ directory +# +# Provides: +# page_react() - Creates HTML page with React mounting point +# render_json - Decorator to render arbitrary JSON data to React +# post_message() - Sends custom messages to React components +# +# License: MIT 2025, Posit Software, PBC +# Source: https://github.com/wch/create-shiny-react-app/blob/main/templates/2-scaffold/py/shinyreact.py + +from __future__ import annotations + +from shiny import ui, Session +from shiny.html_dependencies import shiny_deps +from shiny.types import Jsonifiable +from shiny.render.renderer import Renderer, ValueFn +from typing import Any, Mapping, Optional, Sequence, Union + + +def page_bare(*args: ui.TagChild, title: str | None = None, lang: str = "en") -> ui.Tag: + return ui.tags.html( + ui.tags.head(ui.tags.title(title)), + ui.tags.body(shiny_deps(False), *args), + lang=lang, + ) + + +def page_react( + *args: ui.TagChild, + title: str | None = None, + js_file: str | None = "main.js", + css_file: str | None = "main.css", + lang: str = "en", +) -> ui.Tag: + + head_items: list[ui.TagChild] = [] + + if js_file: + head_items.append(ui.tags.script(src=js_file, type="module")) + if css_file: + head_items.append(ui.tags.link(href=css_file, rel="stylesheet")) + + return page_bare( + ui.head_content(*head_items), + ui.div(id="root"), + *args, + title=title, + lang=lang, + ) + + +class render_json(Renderer[Jsonifiable]): + """ + Reactively render arbitrary JSON object. + + This is a generic renderer that can be used to render any Jsonifiable data. + It sends the data to the client-side and let the client-side code handle the + rendering. + + Returns + ------- + : + A decorator for a function that returns a Jsonifiable object. + + """ + + def __init__( + self, + _fn: Optional[ValueFn[Any]] = None, + ) -> None: + super().__init__(_fn) + + async def transform(self, value: Jsonifiable) -> Jsonifiable: + return value + + +# This is like Jsonifiable, but where Jsonifiable uses Dict, List, and Tuple, +# this replaces those with Mapping and Sequence. Because Dict and List are +# invariant, it can cause problems when a parameter is specified as Jsonifiable; +# the replacements are covariant, which solves these problems. +JsonifiableIn = Union[ + str, + int, + float, + bool, + None, + Sequence["JsonifiableIn"], + "JsonifiableMapping", +] + +JsonifiableMapping = Mapping[str, JsonifiableIn] + + +async def post_message(session: Session, type: str, data: JsonifiableIn): + """ + Send a custom message to the client. + + A convenience function for sending custom messages from the Shiny server to + React components using useShinyMessageHandler() hook. This wraps messages in + a standard format and sends them via the "shinyReactMessage" channel. + + Parameters + ---------- + session + The Shiny session object + type + The message type (should match the messageType in + useShinyMessageHandler) + data + The data to send to the client + """ + await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) diff --git a/shiny/shiny-react/references/internals.md b/shiny/shiny-react/references/internals.md new file mode 100644 index 0000000..c6663a4 --- /dev/null +++ b/shiny/shiny-react/references/internals.md @@ -0,0 +1,400 @@ +# shiny-react Internals + +Deep dive into how shiny-react works under the hood. For advanced developers building custom components or debugging. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Registry System](#registry-system) +- [Input Binding Mechanism](#input-binding-mechanism) +- [Output Binding Mechanism](#output-binding-mechanism) +- [Message System](#message-system) +- [Initialization Flow](#initialization-flow) +- [Extending shiny-react](#extending-shiny-react) + +## Architecture Overview + +shiny-react bridges React's component model with Shiny's reactive system through a four-layer architecture: + +1. **React Components Layer**: Your app uses hooks (`useShinyInput`, `useShinyOutput`, `useShinyMessageHandler`) to communicate with Shiny. + +2. **Registry Layer**: Three registries manage the connection between React and Shiny: + - `InputRegistry` - Tracks all inputs and their React state setters + - `OutputRegistry` - Manages output subscriptions and hidden DOM elements + - `MessageRegistry` - Routes custom messages to registered handlers + +3. **Shiny JavaScript API Layer**: The registries translate React operations into Shiny's native API: + - Inputs call `Shiny.setInputValue()` with debouncing + - Outputs use a custom `OutputBinding` class registered with Shiny + - Messages use `Shiny.addCustomMessageHandler()` + +4. **Shiny Server Layer**: Your R or Python backend receives inputs (`input$id`), sends outputs (`output$id <- render_*()`), and can push messages (`post_message()`). + +``` +React Hooks (useShinyInput, useShinyOutput, useShinyMessageHandler) + │ + ▼ +Registries (InputRegistry, OutputRegistry, MessageRegistry) + │ + ▼ +Shiny JS API (setInputValue, OutputBinding, addCustomMessageHandler) + │ + ▼ +Shiny Server R/Python (input$id, output$id, post_message) +``` + +## Registry System + +### ReactRegistry (react-registry.ts) + +Central registry that holds references to input and output registries: + +```typescript +export interface ShinyReactRegistry { + inputs: InputRegistry; + outputs: OutputRegistry; +} + +// Attached to window.Shiny.reactRegistry +function initializeReactRegistry() { + const shiny = getShiny(); + shiny.reactRegistry = { + inputs: new InputRegistry(), + outputs: new OutputRegistry(), + }; +} +``` + +### InputRegistry (input-registry.ts) + +Manages all React-to-Shiny input bindings: + +```typescript +class InputRegistry { + private inputs: Map> = new Map(); + + getOrCreate(inputId: string, value: T): InputRegistryEntry; + get(inputId: string): InputRegistryEntry | undefined; + has(inputId: string): boolean; +} +``` + +Each input has an `InputRegistryEntry`: + +```typescript +class InputRegistryEntry { + id: string; + value: T; + useStateSetValueFns: Set<(value: T) => void>; // React state setters + shinySetInputValueDebounced: DebouncedFunction; + opts: { priority?: EventPriority; debounceMs: number }; + + setValue(value: T) { + this.value = value; + this.shinySetInputValueDebounced(value); // Send to Shiny + this.useStateSetValueFns.forEach(fn => fn(value)); // Update React + } +} +``` + +Key behaviors: +- Multiple React components can share an input ID +- Values persist across component remounts +- Debouncing prevents excessive server calls + +### OutputRegistry (output-registry.ts) + +Manages Shiny-to-React output bindings: + +```typescript +class OutputRegistry { + private outputs: Map> = new Map(); + private container: HTMLElement; // Hidden DOM container + + add(outputId: string, setValue, setRecalculating); + remove(outputId: string); + has(outputId: string): boolean; +} +``` + +Each output has an `OutputRegistryEntry`: + +```typescript +class OutputRegistryEntry { + id: string; + private useStateSetValueFns: Set<(value: T) => void>; + private useStateSetRecalculatingFns: Set<(value: boolean) => void>; + + setValue(value: T); // Called by output binding + setRecalculating(value: boolean); // Called during recalculation +} +``` + +## Input Binding Mechanism + +When `useShinyInput` is called: + +```typescript +function useShinyInput(id: string, defaultValue: T, options?) { + // 1. Ensure registry is initialized + ensureShinyReactInitialized(); + + // 2. Get or create registry entry + const reactRegistry = getReactRegistry(); + let startValue = defaultValue; + const existingEntry = reactRegistry.inputs.get(id); + if (existingEntry) { + startValue = existingEntry.getValue(); // Preserve existing value + } + + // 3. Create React state + const [value, setValue] = useState(startValue); + + // 4. Register this component's setState with the entry + useEffect(() => { + const entry = reactRegistry.inputs.getOrCreate(id, defaultValue); + entry.addUseStateSetValueFn(setValue); + + return () => { + entry.removeUseStateSetValueFn(setValue); + }; + }, [id]); + + // 5. Return wrapped setter that goes through registry + const setValueWrapped = useCallback((value: T) => { + const entry = reactRegistry.inputs.get(id); + entry?.setValue(value); // Updates Shiny AND all React subscribers + }, [id]); + + return [value, setValueWrapped]; +} +``` + +**Data flow when value changes**: When the user calls the setter function, it triggers `InputRegistryEntry.setValue()`, which does two things in parallel: (1) sends the value to Shiny via a debounced `setInputValue()` call, updating `input$id` on the server, and (2) immediately updates all React components subscribed to this input, triggering re-renders. + +``` +setValue(newValue) + │ + ├──> Shiny.setInputValue() ──> Server input$id + │ + └──> React setState() ──> Component re-render +``` + +## Output Binding Mechanism + +Outputs use Shiny's OutputBinding system with hidden DOM elements: + +```typescript +function useShinyOutput(outputId: string, defaultValue?) { + const [value, setValue] = useState(defaultValue); + const [recalculating, setRecalculating] = useState(false); + + useEffect(() => { + const reactRegistry = getReactRegistry(); + + // Register with output registry + // This creates a hidden
in the DOM + reactRegistry.outputs.add(outputId, setValue, setRecalculating); + + return () => { + reactRegistry.outputs.remove(outputId); + }; + }, [outputId]); + + return [value, recalculating]; +} +``` + +The custom OutputBinding: + +```typescript +class ReactOutputBinding extends Shiny.OutputBinding { + find(scope) { + return $(scope).find(".shiny-react-output"); + } + + renderValue(el, data) { + // Get registry entry and update React state + const entry = Shiny.reactRegistry.outputs.get(el.id); + entry?.setValue(data); + } + + showProgress(el, show) { + // Update recalculating state + const entry = Shiny.reactRegistry.outputs.get(el.id); + entry?.setRecalculating(show); + } +} +``` + +Hidden DOM structure created by OutputRegistry: + +```html + +``` + +**Data flow for outputs**: When the Shiny server sets an output value (e.g., `output$id <- render_json({...})`), the JSON data is sent to the browser via WebSocket. Shiny's binding system finds the hidden DOM element with matching ID and calls `ReactOutputBinding.renderValue()`, which retrieves the `OutputRegistryEntry` and calls `setValue()`. This updates all React components subscribed to that output via their setState functions. + +``` +Server output$id ──> WebSocket ──> OutputBinding.renderValue() ──> React setState() +``` + +## Message System + +### MessageRegistry (message-registry.ts) + +Manages custom message handlers: + +```typescript +class ShinyMessageRegistry { + private handlers: Map void>> = new Map(); + + addHandler(type: string, handler: (data: any) => void); + removeHandler(type: string, handler: (data: any) => void); + dispatch(type: string, data: any); +} +``` + +Initialization registers with Shiny's custom message system: + +```typescript +function initializeMessageRegistry() { + const shiny = getShiny(); + shiny.messageRegistry = new ShinyMessageRegistry(); + + // Register global handler for "shinyReactMessage" type + Shiny.addCustomMessageHandler("shinyReactMessage", (message) => { + shiny.messageRegistry.dispatch(message.type, message.data); + }); +} +``` + +Server-side `post_message()` wraps messages: + +```r +# R +post_message <- function(session, type, data) { + session$sendCustomMessage("shinyReactMessage", list(type = type, data = data)) +} +``` + +## Initialization Flow + +When shiny-react loads: + +```typescript +let shinyReactInitialized = false; + +function ensureShinyReactInitialized() { + if (shinyReactInitialized) return; + + // 1. Create registries + initializeReactRegistry(); + + // 2. Register output binding with Shiny + createReactOutputBinding(); + + // 3. Set up message handler + initializeMessageRegistry(); + + shinyReactInitialized = true; +} +``` + +Timing with Shiny initialization: + +```typescript +function useShinyInitialized(): boolean { + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + const shiny = getShiny(); + shiny?.initializedPromise.then(() => { + setInitialized(true); + }); + }, []); + + return initialized; +} +``` + +## Extending shiny-react + +### Custom Input Type + +Create a hook for specialized input behavior: + +```typescript +function useShinySlider(id: string, defaultValue: number, range: [number, number]) { + const [value, setValue] = useShinyInput(id, defaultValue, { + debounceMs: 50, // Fast updates for sliders + }); + + // Clamp value to range + const setValueClamped = useCallback((v: number) => { + setValue(Math.max(range[0], Math.min(range[1], v))); + }, [setValue, range]); + + return [value, setValueClamped] as const; +} +``` + +### Custom Output Processor + +Process output data before React receives it: + +```typescript +function useProcessedOutput( + outputId: string, + processor: (data: T) => R, + defaultValue?: R +): [R | undefined, boolean] { + const [raw, recalculating] = useShinyOutput(outputId, undefined); + + const processed = useMemo(() => { + return raw ? processor(raw) : defaultValue; + }, [raw, processor, defaultValue]); + + return [processed, recalculating]; +} + +// Usage: Convert column-major to row-major +const [rows] = useProcessedOutput( + "data", + (data) => transpose(data), + [] +); +``` + +### Direct Registry Access + +For advanced use cases: + +```typescript +// Access registries directly +const shiny = window.Shiny as ShinyClassExtended; +const inputRegistry = shiny.reactRegistry.inputs; +const outputRegistry = shiny.reactRegistry.outputs; + +// Manually trigger input update +inputRegistry.get("myInput")?.setValue(newValue); + +// Check if output is registered +outputRegistry.has("myOutput"); +``` + +### Debugging + +```typescript +// Log all registered inputs +const shiny = window.Shiny as ShinyClassExtended; +for (const id of shiny.reactRegistry.inputs.keys()) { + console.log(`Input: ${id}`, shiny.reactRegistry.inputs.get(id)?.getValue()); +} + +// Monitor Shiny WebSocket messages (dev tools) +// Network tab → WS → filter by "shiny" +``` diff --git a/shiny/shiny-react/references/python-backend.md b/shiny/shiny-react/references/python-backend.md new file mode 100644 index 0000000..e776400 --- /dev/null +++ b/shiny/shiny-react/references/python-backend.md @@ -0,0 +1,407 @@ +# Python Backend Reference + +Complete guide for building shiny-react apps with Python Shiny backends. + +## Table of Contents + +- [Setup](#setup) +- [shinyreact.py Functions](#shinyreactpy-functions) +- [Rendering Patterns](#rendering-patterns) +- [Message Handling](#message-handling) +- [Complete Example](#complete-example) + +## Setup + +### File Structure + +``` +myapp/ +├── py/ +│ ├── app.py # Main Shiny application +│ ├── shinyreact.py # Utility functions (copy from template) +│ └── www/ # Built JS/CSS from esbuild +│ ├── main.js +│ └── main.css +``` + +### Minimal app.py + +```python +from shiny import App, Inputs, Outputs, Session +from shinyreact import page_react, render_json +from pathlib import Path + +def server(input: Inputs, output: Outputs, session: Session): + # Your server logic here + pass + +app = App( + page_react(title="My App"), + server, + static_assets=str(Path(__file__).parent / "www"), +) +``` + +**Note:** The `static_assets` parameter is required to serve the built JS/CSS files. + +## shinyreact.py Functions + +### page_react() + +Creates the HTML page shell for React apps. + +```python +def page_react( + *args, # Additional UI elements + title: str | None = None, # Page title + js_file: str | None = "main.js", # JavaScript bundle + css_file: str | None = "main.css", # CSS file + lang: str = "en" # HTML lang attribute +) -> ui.Tag +``` + +**Example:** +```python +ui = page_react( + title="My Dashboard", + js_file="main.js", + css_file="main.css" +) +``` + +### @render_json + +Decorator for rendering arbitrary Python objects as JSON. + +```python +class render_json(Renderer[Jsonifiable]): + """Render any JSON-serializable data to React.""" +``` + +**Examples:** + +```python +# Simple values +@render_json +def greeting(): + return f"Hello, {input.name()}" + +# Dictionaries become JSON objects +@render_json +def stats(): + return { + "mean": float(df["mpg"].mean()), + "std": float(df["mpg"].std()), + "count": len(df) + } + +# DataFrames - convert to column-major format +@render_json +def table_data(): + return df.head(input.num_rows()).to_dict(orient="list") + +# Lists become JSON arrays +@render_json +def items(): + return ["apple", "banana", "cherry"] +``` + +**Important:** For pandas DataFrames, use `.to_dict(orient="list")` to get column-major format matching React expectations. + +### post_message() + +Send custom messages from server to React (async function). + +```python +async def post_message( + session: Session, + type: str, # Message type (matches useShinyMessageHandler) + data: JsonifiableIn # Any JSON-serializable data +) +``` + +**Examples:** + +```python +# Toast notification +await post_message(session, "toast", { + "text": "File saved successfully", + "type": "success" +}) + +# Progress update +await post_message(session, "progress", { + "percent": 75, + "message": "Processing..." +}) +``` + +## Rendering Patterns + +### Reactive Data with Shiny Core + +```python +from shiny import App, Inputs, Outputs, Session, reactive +from shinyreact import page_react, render_json +import pandas as pd + +mtcars = pd.read_csv("mtcars.csv") + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.calc + def filtered_data(): + df = mtcars.copy() + df = df[df["cyl"] >= input.min_cyl()] + df = df[df["mpg"] >= input.min_mpg()] + return df + + @render_json + def table(): + return filtered_data().to_dict(orient="list") + + @render_json + def summary(): + df = filtered_data() + return { + "count": len(df), + "avg_mpg": round(df["mpg"].mean(), 1), + "avg_hp": round(df["hp"].mean(), 0) + } + +app = App(page_react(title="Data Explorer"), server) +``` + +### Multiple Related Outputs + +```python +@render_json +def chart_data(): + return { + "x": mtcars["wt"].tolist(), + "y": mtcars["mpg"].tolist(), + "labels": mtcars.index.tolist() + } + +@render_json +def chart_options(): + return { + "title": input.chart_title(), + "showLegend": input.show_legend(), + "colorScheme": input.color_scheme() + } +``` + +### Plots with @render.plot + +```python +from shiny import render +import matplotlib.pyplot as plt + +@render.plot +def myplot(): + fig, ax = plt.subplots() + ax.scatter(mtcars["wt"], mtcars["mpg"]) + ax.set_xlabel("Weight") + ax.set_ylabel("MPG") + return fig +``` + +## Message Handling + +### Reactive Effects with Messages + +```python +from shiny import reactive + +@reactive.effect +@reactive.event(input.submit) +async def handle_submit(): + # Long computation + result = expensive_calculation() + + await post_message(session, "complete", { + "success": True, + "message": "Calculation finished", + "result": result + }) +``` + +### Periodic Updates + +```python +from shiny import reactive +import asyncio + +@reactive.effect +async def heartbeat(): + while True: + await asyncio.sleep(5) # Every 5 seconds + await post_message(session, "heartbeat", { + "time": datetime.now().isoformat(), + "status": "connected" + }) + reactive.invalidate_later(5) +``` + +### Streaming Data + +```python +@reactive.effect +@reactive.event(input.start_stream) +async def stream_data(): + for i in range(100): + await post_message(session, "stream", { + "progress": i + 1, + "data": generate_chunk(i) + }) + await asyncio.sleep(0.1) + + await post_message(session, "stream", { + "progress": 100, + "done": True + }) +``` + +## Complete Example + +### app.py + +```python +from shiny import App, Inputs, Outputs, Session, reactive, render +from shinyreact import page_react, render_json, post_message +from pathlib import Path +import pandas as pd + +# Load data +mtcars = pd.read_csv("mtcars.csv") + +def server(input: Inputs, output: Outputs, session: Session): + @reactive.calc + def filtered(): + df = mtcars.copy() + cylinders = input.cylinders() + if cylinders: + df = df[df["cyl"].isin(cylinders)] + df = df[df["mpg"] >= input.min_mpg()] + return df + + @render_json + def car_data(): + return filtered().to_dict(orient="list") + + @render_json + def summary(): + df = filtered() + return { + "total": len(df), + "avg_mpg": round(df["mpg"].mean(), 1) if len(df) > 0 else 0, + "avg_hp": round(df["hp"].mean(), 0) if len(df) > 0 else 0 + } + + @render.plot + def scatter(): + import matplotlib.pyplot as plt + df = filtered() + fig, ax = plt.subplots() + for cyl in df["cyl"].unique(): + subset = df[df["cyl"] == cyl] + ax.scatter(subset["wt"], subset["mpg"], label=f"{cyl} cyl") + ax.set_xlabel("Weight") + ax.set_ylabel("MPG") + ax.legend() + return fig + + @reactive.effect + async def warn_on_few_results(): + if len(filtered()) < 5: + await post_message(session, "warning", { + "text": "Very few cars match your filters" + }) + +app = App( + page_react(title="Car Explorer"), + server, + static_assets=str(Path(__file__).parent / "www"), +) +``` + +## Running the App + +### Development + +```bash +cd py +shiny run app.py --reload --port 8000 +``` + +### With npm scripts (recommended) + +```json +{ + "scripts": { + "shinyapp-py": "cd py && shiny run app.py --reload --port ${PY_PORT:-8001}" + } +} +``` + +```bash +npm run shinyapp-py +# or +PY_PORT=8002 npm run shinyapp-py +``` + +## Type Hints + +The `shinyreact.py` module includes proper type hints: + +```python +from typing import Mapping, Sequence, Union + +JsonifiableIn = Union[ + str, int, float, bool, None, + Sequence["JsonifiableIn"], + Mapping[str, "JsonifiableIn"] +] +``` + +Use these types for better IDE support when working with message data. + +## Python-Specific Gotchas + +### Object Identity vs Equality + +Python Shiny uses **object identity** (not equality) to determine if a reactive value changed. Mutating an object in place won't trigger updates: + +```python +# WRONG - same object identity, no reactivity triggered +@reactive.effect +def update_list(): + current = items.get() + current.append(new_item) # Mutates in place + items.set(current) # Same object - no update! + +# CORRECT - create new object with new identity +@reactive.effect +def update_list(): + current = items.get() + new_list = current[:] # Copy creates new identity + new_list.append(new_item) + items.set(new_list) # New object - triggers update +``` + +For dicts, use `dict(current)` or `{**current, "key": value}`. For lists, use `list(current)` or `current[:]`. + +### Preventing Dependency Loops + +Use `@reactive.event` to explicitly declare triggers, or `reactive.isolate()` to read values without creating dependencies: + +```python +# Only runs when submit button clicked, not when count changes +@reactive.effect +@reactive.event(input.submit) +def handle_submit(): + # Read count without creating dependency + with reactive.isolate(): + current_count = count.get() + # Process... +``` diff --git a/shiny/shiny-react/references/r-backend.md b/shiny/shiny-react/references/r-backend.md new file mode 100644 index 0000000..9f944cb --- /dev/null +++ b/shiny/shiny-react/references/r-backend.md @@ -0,0 +1,355 @@ +# R Backend Reference + +Complete guide for building shiny-react apps with R Shiny backends. + +## Table of Contents + +- [Setup](#setup) +- [shinyreact.R Functions](#shinyreactr-functions) +- [Rendering Patterns](#rendering-patterns) +- [Message Handling](#message-handling) +- [Complete Example](#complete-example) + +## Setup + +### File Structure + +``` +myapp/ +├── r/ +│ ├── app.R # Main Shiny application +│ ├── shinyreact.R # Utility functions (copy from template) +│ └── www/ # Built JS/CSS from esbuild +│ ├── main.js +│ └── main.css +``` + +### Minimal app.R + +```r +library(shiny) + +source("shinyreact.R", local = TRUE) + +server <- function(input, output, session) { + # Your server logic here +} + +shinyApp(ui = page_react(title = "My App"), server = server) +``` + +## shinyreact.R Functions + +These functions are provided in the `shinyreact.R` utility file. + +### page_react() + +Creates the HTML page shell for React apps. + +```r +page_react( + ..., # Additional UI elements + title = NULL, # Page title + js_file = "main.js", # JavaScript bundle (NULL to skip) + css_file = "main.css", # CSS file (NULL to skip) + lang = "en" # HTML lang attribute +) +``` + +**What it does:** +- Creates minimal HTML with jQuery dependency (required by Shiny) +- Includes `
` for React mounting +- Links JS/CSS from `www/` directory + +**Example:** +```r +ui <- page_react( + title = "My Dashboard", + js_file = "main.js", + css_file = "main.css" +) +``` + +### render_json() +Renders arbitrary R objects as JSON for React consumption. + +```r +render_json( + expr, # R expression to evaluate + env = parent.frame(), # Environment for evaluation + quoted = FALSE, # Is expr already quoted? + outputArgs = list() # Additional output arguments +) +``` + +**What it does:** +- Evaluates the expression reactively +- Serializes result via `shiny:::toJSON()` +- Sends to React via custom Shiny output binding + +**Examples:** + +```r +# Simple values +output$greeting <- render_json({ + paste("Hello,", input$name) +}) + +# Data frames (become column-major JSON) +output$table_data <- render_json({ + mtcars[1:input$num_rows, ] +}) + +# Lists become JSON objects +output$stats <- render_json({ + list( + mean = mean(mtcars$mpg), + sd = sd(mtcars$mpg), + n = nrow(mtcars) + ) +}) + +# Nested structures +output$config <- render_json({ + list( + settings = list(theme = "dark", fontSize = 14), + data = head(iris, 5) + ) +}) +``` + +### post_message() + +Send custom messages from server to React. + +```r +post_message(session, type, data) +``` + +**Parameters:** +- `session`: Shiny session object +- `type`: Message type string (matches `useShinyMessageHandler` type) +- `data`: Any JSON-serializable R object + +**Examples:** + +```r +# Toast notification +post_message(session, "toast", list( + text = "File saved successfully", + type = "success" +)) + +# Progress update +post_message(session, "progress", list( + percent = 75, + message = "Processing..." +)) + +# Custom event +post_message(session, "dataUpdate", list( + timestamp = Sys.time(), + rows = nrow(updated_data) +)) +``` + +## Rendering Patterns + +### Reactive Data Processing + +```r +server <- function(input, output, session) { + # Reactive data source + filtered_data <- reactive({ + mtcars %>% + filter(cyl >= input$min_cyl) %>% + filter(mpg >= input$min_mpg) + }) + + # Output uses reactive + output$table <- render_json({ + filtered_data() + }) + + # Derived statistics + output$summary <- render_json({ + df <- filtered_data() + list( + count = nrow(df), + avg_mpg = mean(df$mpg), + avg_hp = mean(df$hp) + ) + }) +} +``` + +### Multiple Related Outputs + +```r +server <- function(input, output, session) { + output$chart_data <- render_json({ + list( + x = mtcars$wt, + y = mtcars$mpg, + labels = rownames(mtcars) + ) + }) + + output$chart_options <- render_json({ + list( + title = input$chart_title, + showLegend = input$show_legend, + colorScheme = input$color_scheme + ) + }) +} +``` + +### Plots with renderPlot + +Standard Shiny `renderPlot()` works with `ImageOutput` component: + +```r +output$myplot <- renderPlot({ + ggplot(mtcars, aes(x = wt, y = mpg)) + + geom_point(size = input$point_size) + + theme_minimal() +}) +``` + +React automatically sends plot dimensions via special inputs: +- `.clientdata_output_myplot_width` +- `.clientdata_output_myplot_height` + +## Message Handling + +### Periodic Updates + +```r +server <- function(input, output, session) { + observe({ + invalidateLater(5000) # Every 5 seconds + + post_message(session, "heartbeat", list( + time = Sys.time(), + status = "connected" + )) + }) +} +``` + +### Event-Driven Messages + +```r +server <- function(input, output, session) { + observeEvent(input$submit, { + # Long computation + result <- expensive_calculation() + + post_message(session, "complete", list( + success = TRUE, + message = "Calculation finished", + result = result + )) + }) +} +``` + +### Streaming Data + +```r +server <- function(input, output, session) { + observeEvent(input$start_stream, { + for (i in 1:100) { + post_message(session, "stream", list( + progress = i, + data = generate_chunk(i) + )) + Sys.sleep(0.1) + } + post_message(session, "stream", list(progress = 100, done = TRUE)) + }) +} +``` + +## Complete Example + +### app.R + +```r +library(shiny) +library(dplyr) + +source("shinyreact.R", local = TRUE) + +server <- function(input, output, session) { + # Filtered data reactive + filtered <- reactive({ + mtcars %>% + filter(cyl %in% input$cylinders) %>% + filter(mpg >= input$min_mpg) + }) + + # Table output + output$car_data <- render_json({ + filtered() + }) + + # Summary statistics + output$summary <- render_json({ + df <- filtered() + list( + total = nrow(df), + avg_mpg = round(mean(df$mpg), 1), + avg_hp = round(mean(df$hp), 0) + ) + }) + + # Plot + output$scatter <- renderPlot({ + ggplot(filtered(), aes(x = wt, y = mpg, color = factor(cyl))) + + geom_point(size = 3) + + theme_minimal() + + labs(title = "Weight vs MPG", color = "Cylinders") + }) + + # Notify when filter changes significantly + observeEvent(filtered(), { + if (nrow(filtered()) < 5) { + post_message(session, "warning", list( + text = "Very few cars match your filters" + )) + } + }) +} + +shinyApp( + ui = page_react(title = "Car Explorer"), + server = server +) +``` + +## Running the App + +### Development + +```bash +# From app directory +R -e "options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port = 8000)" +``` + +### With npm scripts (recommended) + +```json +{ + "scripts": { + "shinyapp-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port=${R_PORT:-8000})\"" + } +} +``` + +```bash +npm run shinyapp-r +# or +R_PORT=8001 npm run shinyapp-r +``` diff --git a/shiny/shiny-react/references/shadcn-setup.md b/shiny/shiny-react/references/shadcn-setup.md new file mode 100644 index 0000000..49e7c74 --- /dev/null +++ b/shiny/shiny-react/references/shadcn-setup.md @@ -0,0 +1,449 @@ +# shadcn/ui Integration + +Guide for using shadcn/ui components and Tailwind CSS with shiny-react. + +## Table of Contents + +- [Overview](#overview) +- [Project Setup](#project-setup) +- [Build Configuration](#build-configuration) +- [Adding Components](#adding-components) +- [Using Components with shiny-react](#using-components-with-shiny-react) +- [Theming](#theming) + +## Overview + +shadcn/ui provides beautiful, accessible React components built with Tailwind CSS. Components are copied into your project (not installed as dependencies), giving you full control over customization. + +## Project Setup + +### 1. Initialize Project Structure + +``` +myapp/ +├── package.json +├── tsconfig.json +├── components.json # shadcn/ui configuration +├── build.ts # Custom build script +├── srcts/ +│ ├── main.tsx +│ ├── globals.css # Tailwind directives + CSS variables +│ ├── css.d.ts # CSS module types +│ ├── lib/ +│ │ └── utils.ts # cn() utility function +│ └── components/ +│ ├── ui/ # shadcn/ui components go here +│ │ ├── button.tsx +│ │ ├── card.tsx +│ │ └── input.tsx +│ └── App.tsx # Your app components +├── r/ +│ └── ... +└── py/ + └── ... +``` + +### 2. package.json Dependencies + +```json +{ + "devDependencies": { + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "chokidar": "^4.0.3", + "concurrently": "^9.0.1", + "esbuild": "^0.25.9", + "esbuild-plugin-tailwindcss": "^1.2.4", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "tailwindcss": "^4.1.6", + "typescript": "^5.9.2" + }, + "dependencies": { + "@posit/shiny-react": "^0.0.16", + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.462.0", + "tailwind-merge": "^2.2.0" + } +} +``` + +### 3. tsconfig.json with Path Aliases + +```json +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "noEmit": true, + "moduleResolution": "node", + "lib": ["es2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "@/*": ["./srcts/*"] + } + }, + "include": ["srcts/**/*.ts", "srcts/**/*.tsx", "srcts/**/*.d.ts"] +} +``` + +### 4. components.json + +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} +``` + +### 5. srcts/lib/utils.ts + +```typescript +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +### 6. srcts/globals.css + +```css +@import "tailwindcss"; + +@layer base { + :root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --radius: 0.625rem; + } + + .dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + /* ... dark mode values */ + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} +``` + +### 7. srcts/css.d.ts + +```typescript +declare module "*.css" { + const content: { [className: string]: string }; + export default content; +} +``` + +## Build Configuration + +### build.ts (with Tailwind processing) + +```typescript +import chokidar from "chokidar"; +import * as esbuild from "esbuild"; +import tailwindPlugin from "esbuild-plugin-tailwindcss"; + +const production = process.argv.includes("--production"); +const watch = process.argv.includes("--watch"); + +async function main() { + const config: esbuild.BuildOptions = { + entryPoints: ["srcts/main.tsx"], + outfile: "r/www/main.js", // or py/www/main.js + bundle: true, + format: "esm", + minify: production, + sourcemap: production ? undefined : "linked", + alias: { react: "react" }, + logLevel: "info", + plugins: [tailwindPlugin()], + }; + + if (watch) { + const context = await esbuild.context(config); + await context.rebuild(); + + chokidar.watch(["srcts/", "tailwind.config.js"], { + ignored: ["**/node_modules/**"], + ignoreInitial: true, + }).on("all", async () => { + await context.rebuild(); + }); + } else { + await esbuild.build(config); + } +} + +main(); +``` + +### package.json Scripts + +```json +{ + "scripts": { + "build": "npx tsx build.ts --production", + "watch": "npx tsx build.ts --watch", + "dev": "concurrently \"npm run watch\" \"npm run shinyapp-r\"" + } +} +``` + +## Adding Components + +### Install via CLI + +```bash +# Add individual components +npx shadcn@latest add button card input badge + +# Add all components +npx shadcn@latest add --all +``` + +Components are installed to `srcts/components/ui/`. + +### Manual Installation + +Copy component code from [ui.shadcn.com](https://ui.shadcn.com/docs/components) to `srcts/components/ui/`. + +## Using Components with shiny-react + +### Example: Card with Input + +```typescript +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { useShinyInput, useShinyOutput } from "@posit/shiny-react"; + +export function TextInputCard() { + const [text, setText] = useShinyInput("user_text", ""); + const [processed, loading] = useShinyOutput("processed_text", ""); + const [length] = useShinyOutput("text_length", 0); + + return ( + + + Text Input + + + setText(e.target.value)} + /> +
+
{processed || "No text yet"}
+
+ Length: {length} +
+
+ ); +} +``` + +### Example: Button with Event Priority + +```typescript +import { Button } from "@/components/ui/button"; +import { useShinyInput, useShinyOutput } from "@posit/shiny-react"; + +export function ClickCounter() { + const [clicks, setClicks] = useShinyInput("button_clicks", 0, { + priority: "event", // Immediate handling for buttons + }); + const [serverCount] = useShinyOutput("click_count", 0); + + return ( +
+ +

+ Server confirmed: {serverCount} clicks +

+
+ ); +} +``` + +### Example: Plot in Card + +```typescript +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ImageOutput } from "@posit/shiny-react"; +import { useState } from "react"; + +export function PlotCard() { + const [loading, setLoading] = useState(false); + + return ( + + + + {loading ? "Generating Plot..." : "Data Visualization"} + + + + + + + ); +} +``` + +## Theming + +### Customizing Colors + +Edit CSS variables in `globals.css`: + +```css +:root { + --primary: oklch(0.6 0.25 250); /* Blue primary */ + --primary-foreground: oklch(1 0 0); + /* ... */ +} +``` + +### Dark Mode + +Add dark class to root element: + +```typescript +// Toggle dark mode +document.documentElement.classList.toggle("dark"); +``` + +### Using Brand Colors + +Combine with brand.yml by mapping brand colors to CSS variables: + +```css +:root { + --primary: var(--brand-primary, oklch(0.205 0 0)); + --accent: var(--brand-accent, oklch(0.97 0 0)); +} +``` + +## Common Patterns + +### Loading States + +```typescript +const [data, loading] = useShinyOutput("data", undefined); + +return ( + + + {loading ? ( + + ) : ( + + )} + + +); +``` + +### Form Layout + +```typescript + + + Settings + + +
+
+ + +
+
+ + +
+
+ +
+
+``` + +### Dashboard Layout + +```typescript +export function App() { + return ( +
+
+

Dashboard

+ +
+ + + +
+
+
+ ); +} +``` diff --git a/shiny/shiny-react/references/typescript-api.md b/shiny/shiny-react/references/typescript-api.md new file mode 100644 index 0000000..6d31739 --- /dev/null +++ b/shiny/shiny-react/references/typescript-api.md @@ -0,0 +1,263 @@ +# TypeScript API Reference + +Complete API reference for `@posit/shiny-react` hooks and components. + +## Table of Contents + +- [useShinyInput](#useshinyinput) +- [useShinyOutput](#useshinyoutput) +- [useShinyMessageHandler](#useshinymessagehandler) +- [useShinyInitialized](#useshinyinitialized) +- [ImageOutput Component](#imageoutput-component) + +## useShinyInput + +Send data from React to Shiny server. + +```typescript +function useShinyInput( + id: string, + defaultValue: T, + options?: { + debounceMs?: number; // Debounce delay (default: 100ms) + priority?: EventPriority; // "deferred" | "event" | "immediate" + } +): [T, (value: T) => void] +``` + +### Parameters + +- `id`: Shiny input ID (accessed as `input$id` in R or `input.id()` in Python) +- `defaultValue`: Initial value before any user interaction +- `options.debounceMs`: Milliseconds to wait after value changes before sending to server +- `options.priority`: Event priority for Shiny's reactive system + +### Returns + +Tuple of `[currentValue, setValue]` similar to React's useState. + +### Examples + +```typescript +// Basic text input +const [text, setText] = useShinyInput("user_text", ""); + +// Number with longer debounce +const [count, setCount] = useShinyInput("counter", 0, { debounceMs: 500 }); + +// Button clicks with immediate priority +const [clicks, setClicks] = useShinyInput("button_clicks", 0, { + priority: "event" +}); +``` + +### Behavior Notes + +- Value is sent to Shiny via `window.Shiny.setInputValue()` after debounce +- Multiple components can share the same input ID - they'll stay synchronized +- The hook preserves values across component remounts + +## useShinyOutput + +Receive reactive data from Shiny server outputs. + +```typescript +function useShinyOutput( + outputId: string, + defaultValue?: T +): [T | undefined, boolean] +``` + +### Parameters + +- `outputId`: Shiny output ID (set via `output$id` in R or `@output` in Python) +- `defaultValue`: Value to use before first server update + +### Returns + +Tuple of `[value, recalculating]`: +- `value`: Current output value from server +- `recalculating`: `true` while server is computing new value + +### Examples + +```typescript +// Simple string output +const [message, loading] = useShinyOutput("status_message", ""); + +// Complex typed output +interface ChartData { + labels: string[]; + values: number[]; +} +const [chartData, isLoading] = useShinyOutput("chart_data", undefined); + +// Show loading state +{isLoading ? : } +``` + +### Data Format Patterns + +**Strings and numbers**: Pass through directly + +**Data frames** (R/Python): Serialize as column-major JSON objects +```typescript +// R: output$df <- render_json({ mtcars }) +type DataFrame = Record; +const [df] = useShinyOutput("df", undefined); + +// Access: df?.mpg[0], df?.cyl[1], etc. +``` + +**Lists/Dicts**: Serialize as JSON objects +```typescript +interface Stats { mean: number; sd: number; n: number; } +const [stats] = useShinyOutput("statistics", undefined); +``` + +**Arrays**: Serialize as JSON arrays +```typescript +const [items] = useShinyOutput("item_list", []); +``` + +## useShinyMessageHandler + +Handle custom messages sent from Shiny server via `post_message()`. + +```typescript +function useShinyMessageHandler( + messageType: string, + handler: (data: T) => void +): void +``` + +### Parameters + +- `messageType`: Message type identifier (must match `type` in `post_message()`) +- `handler`: Callback function invoked when message is received + +### Examples + +```typescript +// Toast notifications +useShinyMessageHandler("toast", (msg: { text: string; type: string }) => { + showToast(msg.text, msg.type); +}); + +// Progress updates +useShinyMessageHandler("progress", (data: { percent: number }) => { + setProgress(data.percent); +}); + +// Streaming data (e.g., LLM responses) +useShinyMessageHandler("stream_chunk", (chunk: { text: string; done: boolean }) => { + if (chunk.done) { + setStreaming(false); + } else { + appendToResponse(chunk.text); + } +}); +``` + +### Behavior Notes + +- Handler is automatically cleaned up when component unmounts +- Re-registering with same messageType replaces the previous handler +- Handler should be wrapped in `useCallback` if it has dependencies + +## useShinyInitialized + +Check if Shiny has finished initializing. + +```typescript +function useShinyInitialized(): boolean +``` + +### Returns + +`true` once `window.Shiny.initializedPromise` resolves. + +### Use Cases + +```typescript +const shinyReady = useShinyInitialized(); + +if (!shinyReady) { + return
Connecting to server...
; +} + +return ; +``` + +## ImageOutput Component + +Display Shiny plot/image outputs with automatic sizing. + +```typescript +function ImageOutput(props: { + id: string; // Shiny output ID (from renderPlot/renderImage) + className?: string; // CSS class for the element + width?: string; // CSS width (e.g., "100%", "300px") + height?: string; // CSS height (e.g., "400px", "50vh") + debounceMs?: number; // Resize debounce (default: 400ms) + onRecalculating?: (isRecalculating: boolean) => void; +}): JSX.Element +``` + +### Key Features + +- Automatically sends dimensions to Shiny for server-side plot generation +- Uses ResizeObserver to update dimensions on resize +- Handles Shiny's recalculating state + +### Examples + +```typescript +// Fixed height, responsive width + + +// Full viewport height + + +// With loading callback + setIsLoading(loading)} +/> +``` + +### CSS-Controlled Sizing + +```typescript +// Use CSS class for sizing + +``` + +```css +.dashboard-plot { + width: 100%; + height: 100%; + min-height: 300px; +} +``` + +### Backend (R) + +```r +output$myplot <- renderPlot({ + # Plot code - dimensions come from ImageOutput automatically + ggplot(data, aes(x, y)) + geom_point() +}) +``` + +### Backend (Python) + +```python +@render.plot +def myplot(): + fig, ax = plt.subplots() + ax.scatter(data['x'], data['y']) + return fig +```