diff --git a/shiny/shiny-react/SKILL.md b/shiny/shiny-react/SKILL.md index 457e73b..a9175f3 100644 --- a/shiny/shiny-react/SKILL.md +++ b/shiny/shiny-react/SKILL.md @@ -3,10 +3,12 @@ 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. + existing Shiny apps, (3) Creating reusable React widgets using ShinyReactComponentElement + or custom web elements, (4) Using shadcn/ui or other React component libraries with Shiny, + (5) Understanding useShinyInput/useShinyOutput hooks, (6) Setting up bidirectional + communication between React and R/Python Shiny backends, (7) Building modern data + dashboards with React and Shiny, (8) Implementing dynamic widget rendering with + insertUI/removeUI. Supports both R and Python Shiny backends. --- # shiny-react @@ -118,14 +120,188 @@ When writing React components that communicate with Shiny: if (isLoading) return ; ``` +7. **Use namespaces for multiple widget instances** - When embedding multiple instances of the same React widget, wrap them in `ShinyModuleProvider` to prevent ID conflicts: + ```typescript + import { ShinyModuleProvider } from "@posit/shiny-react"; + + + + + ``` + +8. **Create reusable widgets with ShinyReactComponentElement** - For self-contained React widgets that can be embedded in Shiny apps, extend `ShinyReactComponentElement` for automatic lifecycle management and Shiny integration. See the "ShinyReactComponentElement Base Class" section below for the recommended approach. + +## Shiny Module Namespaces + +When to use namespaces: + +- **Multiple widget instances** - Same React component used multiple times on one page +- **Shiny module integration** - React widgets inside Shiny modules (`moduleServer` in R, `@module.server` in Python) +- **Reusable components** - Creating widget libraries that work like standard Shiny UI components + +### Client-Side Pattern + +```typescript +import { ShinyModuleProvider } from "@posit/shiny-react"; + +// Wrap the widget in ShinyModuleProvider + + + + +// All hooks inside automatically namespace their IDs +function CounterWidget() { + const [count, setCount] = useShinyInput("count", 0); + // If namespace="counter1", this becomes "counter1-count" +} +``` + +### Server-Side Pattern + +Use standard Shiny module patterns. The `post_message()` function automatically namespaces messages: + +**R:** +```r +counter_ui <- function(id, title = "Counter") { + card( + card_header(title), + tags$tag("counter-widget", list(id = id)) + ) +} + +counter_server <- function(id) { + moduleServer(id, function(input, output, session) { + # input$count is automatically namespaced by Shiny + output$serverCount <- render_json({ input$count * 2 }) + + # post_message automatically applies session$ns() + post_message(session, "notification", list(text = "Updated!")) + + # Return reactive for use elsewhere + reactive({ input$count }) + }) +} +``` + +**Python:** +```python +def counter_ui(id: str, title: str = "Counter"): + return ui.card( + ui.card_header(title), + ui.HTML(f'') + ) + +@module.server +def counter_server(input, output, session): + @render_json + def serverCount(): + return input.count() * 2 + + # post_message automatically applies resolve_id() + await post_message(session, "notification", {"text": "Updated!"}) + + @reactive.calc + def count(): + return input.count() if input.count() is not None else 0 + + return count # Return reactive for use elsewhere +``` + +### ShinyReactComponentElement Base Class (Recommended) + +For self-contained React widgets, extend `ShinyReactComponentElement` - a base class that handles React lifecycle, Shiny bindings, and namespace support automatically. + +**Simple widget:** +```typescript +import { ShinyReactComponentElement } from "@posit/shiny-react"; +import { CounterWidget } from "./CounterWidget"; + +class CounterWidgetElement extends ShinyReactComponentElement { + static component = CounterWidget; +} + +if (!customElements.get("counter-widget")) { + customElements.define("counter-widget", CounterWidgetElement); +} +``` + +The base class automatically: +- Creates React root and renders your component +- Wraps in `ShinyModuleProvider` if element has an `id` attribute +- Parses `data-*` attributes into props via `getConfig()` (JSON auto-parsing) +- Handles Shiny `bindAll`/`unbindAll` lifecycle +- Cleans up on disconnect + +**Blended component (React layout + Shiny content):** +```typescript +class SidebarLayoutElement extends ShinyReactComponentElement { + protected render() { + const config = this.getConfig(); + return ( + + ); + } +} +``` + +Use `data-slot="name"` in R/Python to create named slots. If no slots exist, all children are captured as `__children__`. + +**Key methods:** +- `getConfig()` - Returns parsed `data-*` attributes as object +- `onSlotMount` - Callback to pass to React for mounting Shiny content +- `mountSlot(name, el)` - Moves captured slot content and calls `Shiny.bindAll()` +- `captureSlots()` - Called automatically; captures `[data-slot]` children +- `clearContent()` - Override with no-op to preserve innerHTML +- `render()` - Override to customize React rendering + +**Using the widget in Shiny:** + +R: +```r +counter_ui <- function(id, title = "Counter", initial_value = 0) { + tagList( + htmlDependency(...), + tag("counter-widget", list( + id = id, + `data-title` = title, + `data-initial-value` = initial_value + )) + ) +} +``` + +Python: +```python +def counter_ui(id: str, title: str = "Counter", initial_value: int = 0): + return ui.TagList( + ui.include_js(...), + ui.HTML(f'') + ) +``` + +**Benefits:** +- Automatic initialization/cleanup with DOM lifecycle +- Works with `insertUI()`/`removeUI()` and `ui.insert_ui()`/`ui.remove_ui()` +- Semantic HTML: `` instead of `
` +- Configuration via attributes flows to React props +- Namespace support via element `id` + +See `examples/8-modules/` and `examples/9-blended/` in the shiny-react repository for complete examples. + ## 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` +2. **Creating reusable React widgets for Shiny?** → Extend `ShinyReactComponentElement` (see "ShinyReactComponentElement Base Class" above) +3. **Extending ShinyReactComponentElement?** → See examples in `examples/8-modules/` and `examples/9-blended/` +4. **Need multiple instances of same widget?** → Use `ShinyModuleProvider` with namespacing (see "Shiny Module Namespaces" above) +5. **Need TypeScript API details?** → Read `references/typescript-api.md` +6. **Setting up R backend?** → Read `references/r-backend.md` +7. **Setting up Python backend?** → Read `references/python-backend.md` +8. **Using shadcn/ui or Tailwind?** → Read `references/shadcn-setup.md` +9. **Understanding internals?** → Read `references/internals.md` ## Project Structure @@ -253,6 +429,7 @@ The [shiny-react repository](https://github.com/wch/shiny-react) includes exampl | `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 | +| `8-modules` | Shiny module namespaces with multiple widget instances (see two variants: full React app and standard Shiny app) | Each example includes complete R and Python backends. diff --git a/shiny/shiny-react/assets/shinyreact.R b/shiny/shiny-react/assets/shinyreact.R index 2c91f8e..ce9aa86 100644 --- a/shiny/shiny-react/assets/shinyreact.R +++ b/shiny/shiny-react/assets/shinyreact.R @@ -77,14 +77,19 @@ render_json <- function( #' React components using useShinyMessageHandler() hook. This wraps messages in a #' standard format and sends them via the "shinyReactMessage" channel. #' +#' When used within a Shiny module (moduleServer), the type is automatically +#' namespaced using session$ns(). Outside of modules, the type is passed through +#' unchanged. +#' #' @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) { + namespaced_type <- session$ns(type) session$sendCustomMessage( "shinyReactMessage", list( - type = type, + type = namespaced_type, data = data ) ) diff --git a/shiny/shiny-react/assets/shinyreact.py b/shiny/shiny-react/assets/shinyreact.py index ee52b96..82b7105 100644 --- a/shiny/shiny-react/assets/shinyreact.py +++ b/shiny/shiny-react/assets/shinyreact.py @@ -15,6 +15,7 @@ from shiny.html_dependencies import shiny_deps from shiny.types import Jsonifiable from shiny.render.renderer import Renderer, ValueFn +from shiny.module import resolve_id from typing import Any, Mapping, Optional, Sequence, Union @@ -33,7 +34,6 @@ def page_react( css_file: str | None = "main.css", lang: str = "en", ) -> ui.Tag: - head_items: list[ui.TagChild] = [] if js_file: @@ -100,6 +100,10 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): React components using useShinyMessageHandler() hook. This wraps messages in a standard format and sends them via the "shinyReactMessage" channel. + When used within a Shiny module (@module.server), the type is automatically + namespaced using resolve_id(). Outside of modules, the type is passed through + unchanged. + Parameters ---------- session @@ -110,4 +114,7 @@ async def post_message(session: Session, type: str, data: JsonifiableIn): data The data to send to the client """ - await session.send_custom_message("shinyReactMessage", {"type": type, "data": data}) + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/shiny/shiny-react/references/typescript-api.md b/shiny/shiny-react/references/typescript-api.md index 6d31739..adfcec7 100644 --- a/shiny/shiny-react/references/typescript-api.md +++ b/shiny/shiny-react/references/typescript-api.md @@ -9,6 +9,7 @@ Complete API reference for `@posit/shiny-react` hooks and components. - [useShinyMessageHandler](#useshinymessagehandler) - [useShinyInitialized](#useshinyinitialized) - [ImageOutput Component](#imageoutput-component) +- [ShinyReactComponentElement](#shinyreactcomponentelement) ## useShinyInput @@ -261,3 +262,137 @@ def myplot(): ax.scatter(data['x'], data['y']) return fig ``` + +## ShinyReactComponentElement + +Base class for creating custom web elements that render React components with automatic Shiny integration. + +```typescript +class ShinyReactComponentElement extends HTMLElement { + // Set on subclass to define the React component to render + static component: React.ComponentType> | null; + + // Protected properties + protected root: Root | null; + protected slotContents: Map; + + // Protected methods + protected getConfig(): Record; + protected captureSlots(selector?: string): Map; + protected mountSlot(slotName: string, container: HTMLElement | null): Promise; + protected get onSlotMount(): (slotName: string, el: HTMLElement | null) => Promise; + protected get namespace(): string | undefined; + protected render(): React.ReactNode; + protected clearContent(): void; + + // Lifecycle + connectedCallback(): void; + disconnectedCallback(): void; +} +``` + +### Features + +- **Automatic namespace support**: Wraps in `ShinyModuleProvider` if element has an `id` +- **Config parsing**: `getConfig()` parses `data-*` attributes with JSON auto-parsing +- **Slot preservation**: Captures `[data-slot]` children for blended React+Shiny content +- **Default slot**: If no `data-slot` elements, all children go to `__children__` slot +- **Shiny lifecycle**: Automatic `bindAll`/`unbindAll` management + +### Simple Widget Example + +```typescript +import { ShinyReactComponentElement } from "@posit/shiny-react"; +import { MyWidget } from "./MyWidget"; + +class MyWidgetElement extends ShinyReactComponentElement { + static component = MyWidget; +} + +if (!customElements.get("my-widget")) { + customElements.define("my-widget", MyWidgetElement); +} +``` + +The component receives parsed `data-*` attributes as props automatically. + +### Blended Component Example + +For React layouts containing Shiny content: + +```typescript +class MySidebarElement extends ShinyReactComponentElement { + protected render() { + const config = this.getConfig(); + return ( + + ); + } +} +``` + +In your React component, call `onSlotMount(slotName, containerEl)` after the container renders to move Shiny content into place. + +### Key Methods + +#### getConfig() + +Parses `data-*` attributes into a props object with JSON auto-parsing: + +```html + +``` + +Returns: `{ count: 5, items: [1,2,3], title: "Hello" }` + +- Numbers/booleans parsed from JSON +- Arrays/objects parsed from JSON +- Invalid JSON stays as string + +#### captureSlots(selector?) + +Called automatically in `connectedCallback()`. Captures children matching selector (default `[data-slot]`). + +If no matching elements found and element has children, all children are stored under the `__children__` slot. + +#### mountSlot(slotName, container) + +Moves captured slot content into the container element and calls `Shiny.bindAll()`. + +#### onSlotMount + +Getter that returns `mountSlot.bind(this)` - pass this to React components as a callback. + +#### clearContent() + +Clears `innerHTML` before React renders. Override with no-op to preserve existing content: + +```typescript +protected clearContent() {} // Keep existing content +``` + +### Override Points + +- **`render()`**: Customize what React renders (default renders `static component` with `getConfig()` props) +- **`getConfig()`**: Customize attribute parsing +- **`clearContent()`**: Override to preserve innerHTML +- **`captureSlots()`**: Override with custom selector + +### HTML Structure + +R: +```r +tag("my-widget", list( + id = "widget1", # Used for namespace + `data-title` = "My Title", # Becomes props.title + `data-count` = 5 # Becomes props.count (number) +)) +``` + +Python: +```python +ui.HTML('') +```