Sidebar
"; + el.appendChild(sidebar); + + const main = document.createElement("div"); + main.setAttribute("data-slot", "main"); + main.innerHTML = "Main
"; + el.appendChild(main); + + const slots = (el as any).captureSlots(); + expect(slots.has("sidebar")).toBe(true); + expect(slots.has("main")).toBe(true); + expect(slots.has("__children__")).toBe(false); + }); + + it("returns empty map when element has no children", () => { + const el = new ShinyReactComponentElement(); + const slots = (el as any).captureSlots(); + expect(slots.size).toBe(0); + }); + }); + + describe("mountSlot", () => { + it("moves captured content into a container and calls Shiny.bindAll", async () => { + const el = new ShinyReactComponentElement(); + const child = document.createElement("div"); + child.textContent = "hello"; + el.appendChild(child); + (el as any).captureSlots(); + + const container = document.createElement("div"); + await (el as any).mountSlot("__children__", container); + + expect(container.childNodes).toHaveLength(1); + expect(container.textContent).toBe("hello"); + expect((window as any).Shiny.bindAll).toHaveBeenCalledWith(container); + }); + + it("does nothing when slot name not found", async () => { + const el = new ShinyReactComponentElement(); + const container = document.createElement("div"); + await (el as any).mountSlot("nonexistent", container); + expect(container.childNodes).toHaveLength(0); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd js && npx vitest run` +Expected: FAIL โ `ShinyReactComponentElement` module not found + +**Step 3: Create `ShinyReactComponentElement.tsx`** + +Create `js/src/shiny-react/ShinyReactComponentElement.tsx` with the exact content from the PR (218 lines). The file is reproduced in the design doc's Section 1 and in the PR diff above. Copy verbatim from the PR diff lines 3750-3967. + +**Step 4: Run tests to verify they pass** + +Run: `cd js && npx vitest run` +Expected: All tests PASS + +**Step 5: Verify TypeScript compiles (with Task 5 exports)** + +Run: `cd js && npx tsc --noEmit` +Expected: No errors + +**Step 6: Commit (combined with Task 5 index.ts changes)** + +```bash +git add js/src/shiny-react/ShinyReactComponentElement.tsx js/src/shiny-react/__tests__/ShinyReactComponentElement.test.tsx js/src/shiny-react/index.ts +git commit -m "feat: add ShinyReactComponentElement base class and update shiny-react exports" +``` + +--- + +### Task 7: Update `window.shinyjson` global API + +**Files:** +- Modify: `js/src/index.ts:16-53` + +**Step 1: Add imports** + +In `js/src/index.ts`, at line 21 (after the `ImageOutput` import), add: + +```typescript +import { + ShinyModuleProvider, + ShinyReactComponentElement, +} from "./shiny-react"; +``` + +**Step 2: Add to type declaration** + +In the `Window.shinyjson` interface (lines 27-39), add: + +```typescript + ShinyModuleProvider: typeof ShinyModuleProvider; + ShinyReactComponentElement: typeof ShinyReactComponentElement; +``` + +**Step 3: Add to runtime object** + +In the `window.shinyjson = { ... }` assignment (lines 44-53), add: + +```typescript + ShinyModuleProvider, + ShinyReactComponentElement, +``` + +**Step 4: Verify TypeScript compiles** + +Run: `cd js && npx tsc --noEmit` +Expected: No errors + +**Step 5: Build the JS bundle** + +Run: `make js-build` +Expected: Build succeeds, `js/dist/shinyjson.js` updated + +**Step 6: Commit** + +```bash +git add js/src/index.ts +git commit -m "feat: expose ShinyModuleProvider and ShinyReactComponentElement on window.shinyjson" +``` + +--- + +### Task 8: Fix Python `post_message` namespacing + +**Files:** +- Modify: `pkg-py/src/shinyjson/_post_message.py:1-36` +- Modify: `pkg-py/tests/test_post_message.py` + +**Step 1: Write the failing test** + +Add to `pkg-py/tests/test_post_message.py`: + +```python + @pytest.mark.asyncio + async def test_namespaces_type_with_resolve_id(self): + """post_message uses resolve_id to namespace the message type.""" + session = AsyncMock() + + # Simulate being inside a Shiny module with namespace "mymod" + with unittest.mock.patch( + "shinyjson._post_message.resolve_id", + side_effect=lambda x: f"mymod-{x}", + ): + await post_message(session, "logEvent", {"text": "hello"}) + + session.send_custom_message.assert_called_once_with( + "shinyReactMessage", + {"type": "mymod-logEvent", "data": {"text": "hello"}}, + ) +``` + +Also add `import unittest.mock` at the top of the file. + +**Step 2: Run test to verify it fails** + +Run: `uv run pytest pkg-py/tests/test_post_message.py::TestPostMessage::test_namespaces_type_with_resolve_id -v` +Expected: FAIL โ `resolve_id` not used yet + +**Step 3: Update `_post_message.py`** + +In `pkg-py/src/shinyjson/_post_message.py`, add the import after line 1: + +```python +from shiny.module import resolve_id +``` + +And change line 34 from: + +```python + await session.send_custom_message( + "shinyReactMessage", {"type": type, "data": data} + ) +``` + +to: + +```python + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest pkg-py/tests/test_post_message.py -v` +Expected: All 4 tests PASS + +**Step 5: Run full Python checks** + +Run: `make py-check` +Expected: All checks pass (format, types, tests) + +**Step 6: Commit** + +```bash +git add pkg-py/src/shinyjson/_post_message.py pkg-py/tests/test_post_message.py +git commit -m "fix: namespace post_message type using resolve_id for module support" +``` + +--- + +### Task 9: Copy upstream examples + +**Files:** +- Create: `examples/shiny-react-upstream/` (entire directory tree) +- Modify: `.gitignore` + +**Step 1: Clone the PR branch and copy examples** + +```bash +# Clone into a temp directory +git clone --depth 1 --branch feat/multiple-react-roots https://github.com/gadenbuie/shiny-react.git /tmp/shiny-react-pr3 + +# Copy all examples +cp -r /tmp/shiny-react-pr3/examples/ examples/shiny-react-upstream/ + +# Copy the README +cp /tmp/shiny-react-pr3/README.md examples/shiny-react-upstream/README.md + +# Clean up +rm -rf /tmp/shiny-react-pr3 +``` + +**Step 2: Add gitignore entry** + +In `.gitignore`, at the end (after `node_modules/`), add: + +``` +# Built assets in shiny-react upstream examples +examples/shiny-react-upstream/*/www/ +``` + +**Step 3: Verify the structure** + +Run: `ls examples/shiny-react-upstream/` +Expected: `1-hello-world/ 2-inputs/ 3-outputs/ 4-messages/ 5-shadcn/ 6-dashboard/ 7-chat/ 8-modules/ 9-blended/ README.md` + +**Step 4: Commit** + +```bash +git add examples/shiny-react-upstream/ .gitignore +git commit -m "chore: copy upstream shiny-react examples as reference material + +Verbatim copies from wch/shiny-react#3 (gadenbuie:feat/multiple-react-roots). +These use the @posit/shiny-react copy-paste pattern and won't run as-is +within shinyjson. They serve as reference for future adaptation." +``` + +--- + +### Task 10: Build and verify everything + +**Files:** +- Modify: `js/dist/shinyjson.js` (rebuilt) +- Modify: `js/dist/shinyjson.css` (rebuilt) +- Modify: `pkg-py/src/shinyjson/www/` (copied) +- Modify: `pkg-r/inst/lib/shiny/` (copied) + +**Step 1: Run all JS tests** + +Run: `make js-test` +Expected: All tests pass + +**Step 2: Run JS lint** + +Run: `make js-lint` +Expected: No errors + +**Step 3: Build and distribute** + +Run: `make update-dist` +Expected: JS builds, assets copied to pkg-py and pkg-r + +**Step 4: Run Python checks** + +Run: `make py-check` +Expected: All checks pass + +**Step 5: Commit built assets** + +```bash +git add js/dist/ pkg-py/src/shinyjson/www/ pkg-r/inst/lib/shiny/ +git commit -m "chore: rebuild JS bundle with module namespace support" +``` diff --git a/examples/shiny-react-upstream/1-hello-world/.gitignore b/examples/shiny-react-upstream/1-hello-world/.gitignore new file mode 100644 index 00000000..90331847 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +r/www/ +py/www/ diff --git a/examples/shiny-react-upstream/1-hello-world/README.md b/examples/shiny-react-upstream/1-hello-world/README.md new file mode 100644 index 00000000..c4e03635 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/README.md @@ -0,0 +1,63 @@ +# Hello World Example + +This is a simple example demonstrating how to use the shiny-react library to create React components that communicate with Shiny applications. + +The front end is implemented with React and TypeScript. There are two versions of the Shiny back end: one is implemented with R, and the other with Python. + +The front end uses `useShinyInput` and `useShinyOutput` hooks to send and receive values from the Shiny back end. The back end is a Shiny application that uses `render_json` to send the output values to the front end as JSON. In this example, the Shiny back end simply capitalizes the input value and sends it back to the front end. + +## Directory Structure + +- **`r/`** - R Shiny application + - `app.R` - Main R Shiny server application + - `shinyreact.R` - R utility functions +- **`py/`** - Python Shiny application + - `app.py` - Main Python Shiny server application + - `shinyreact.py` - Python utility functions +- **`srcts/`** - TypeScript/React source code + - `main.tsx` - Entry point that renders the React app + - `HelloWorldComponent.tsx` - Main React component using shiny-react hooks + - `styles.css` - Simple CSS styling for the application +- **`r/www/`** - Built JavaScript output for R Shiny app (generated) +- **`py/www/`** - Built JavaScript output for Python Shiny app (generated) +- **`node_modules/`** - npm dependencies (generated) + +## Building + +1. Install dependencies: + ```bash + npm install + ``` + +2. Build the React application: + ```bash + npm run build + ``` + + The build process compiles the TypeScript React code and CSS into JavaScript bundles output directly to `r/www/main.js` and `py/www/main.js`. The CSS is automatically bundled into the JavaScript files. + + Or for development with watch mode: + ```bash + npm run watch + ``` + + The watch mode runs three processes concurrently: + - TypeScript type checking in watch mode + - ESBuild bundling for R app (outputs to `r/www/main.js`) + - ESBuild bundling for Python app (outputs to `py/www/main.js`) + + Note that if you build just an R or Python Shiny application (instead of both, as in this example), then you can simplify the `build` and `watch` scripts in `package.json` to only target one output directory. + +3. Run either the R or Python Shiny application: + + ```bash + # For R + R -e "options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port=8000)" + + # For Python + shiny run py/app.py --port 8000 + ``` + + The commands above use port 8000, but you can use a different port. + +4. Open your web browser and navigate to `http://localhost:8000` to see the Shiny-React application in action. diff --git a/examples/shiny-react-upstream/1-hello-world/package.json b/examples/shiny-react-upstream/1-hello-world/package.json new file mode 100644 index 00000000..65c8ca5d --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/package.json @@ -0,0 +1,42 @@ +{ + "private": true, + "name": "shiny-react-hello-world", + "version": "1.0.0", + "type": "module", + "description": "Hello World app using shiny-react", + "scripts": { + "dev": "concurrently -c auto \"npm run watch\" \"npm run shinyapp\"", + "build": "concurrently -c auto \"npm run build-r\" \"npm run build-py\" \"tsc --noEmit\"", + "watch": "concurrently -c auto \"npm run watch-r\" \"npm run watch-py\" \"tsc --noEmit --watch --preserveWatchOutput\"", + "shinyapp": "concurrently -c auto \"npm run shinyapp-r\" \"npm run shinyapp-py\"", + "dev-r": "concurrently -c auto \"npm run watch-r\" \"npm run shinyapp-r\"", + "dev-py": "concurrently -c auto \"npm run watch-py\" \"npm run shinyapp-py\"", + "build-r": "esbuild srcts/main.tsx --bundle --minify --outfile=r/www/main.js --format=esm --alias:react=react", + "build-py": "esbuild srcts/main.tsx --bundle --minify --outfile=py/www/main.js --format=esm --alias:react=react", + "watch-r": "esbuild srcts/main.tsx --bundle --minify --outfile=r/www/main.js --format=esm --alias:react=react --watch", + "watch-py": "esbuild srcts/main.tsx --bundle --minify --outfile=py/www/main.js --format=esm --alias:react=react --watch", + "shinyapp-r": "Rscript -e \"options(shiny.autoreload = TRUE); shiny::runApp('r/app.R', port=${R_PORT:-8000})\"", + "shinyapp-py": "cd py && shiny run app.py --reload --port ${PY_PORT:-8001}", + "clean": "rm -rf r/www py/www" + }, + "author": "Winston Chang", + "license": "MIT", + "devDependencies": { + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "concurrently": "^9.0.1", + "esbuild": "^0.25.9", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "typescript": "^5.9.2" + }, + "dependencies": { + "@posit/shiny-react": "file:../.." + }, + "exampleMetadata": { + "title": "Hello World", + "description": "Basic bidirectional communication between React and Shiny", + "deployToShinylive": true, + "comment": "" + } +} diff --git a/examples/shiny-react-upstream/1-hello-world/py/app.py b/examples/shiny-react-upstream/1-hello-world/py/app.py new file mode 100644 index 00000000..42b92fe9 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/py/app.py @@ -0,0 +1,16 @@ +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): + @render_json + def txtout(): + return input.txtin().upper() + + +app = App( + page_react(title="Hello Shiny React"), + server, + static_assets=str(Path(__file__).parent / "www"), +) diff --git a/examples/shiny-react-upstream/1-hello-world/py/shinyreact.py b/examples/shiny-react-upstream/1-hello-world/py/shinyreact.py new file mode 100644 index 00000000..fe824759 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/py/shinyreact.py @@ -0,0 +1,109 @@ +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 shiny.module import resolve_id +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. + + 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 + The Shiny session object + type + The message type (should match the messageType in + useShinyMessageHandler) + data + The data to send to the client + """ + namespaced_type = resolve_id(type) + await session.send_custom_message( + "shinyReactMessage", {"type": namespaced_type, "data": data} + ) diff --git a/examples/shiny-react-upstream/1-hello-world/r/app.R b/examples/shiny-react-upstream/1-hello-world/r/app.R new file mode 100644 index 00000000..e72cfee0 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/r/app.R @@ -0,0 +1,11 @@ +library(shiny) + +source("shinyreact.R", local = TRUE) + +server <- function(input, output, session) { + output$txtout <- render_json({ + toupper(input$txtin) + }) +} + +shinyApp(ui = page_react(title = "Hello Shiny React"), server = server) diff --git a/examples/shiny-react-upstream/1-hello-world/r/shinyreact.R b/examples/shiny-react-upstream/1-hello-world/r/shinyreact.R new file mode 100644 index 00000000..f7dbe1c8 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/r/shinyreact.R @@ -0,0 +1,87 @@ +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. +#' +#' When called from within a Shiny module, the message type is automatically +#' namespaced using session$ns() to match the React component's namespace. +#' +#' @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) { + # Apply namespace to message type using session$ns() + # session$ns() returns the ID unchanged if not in a module context + namespaced_type <- session$ns(type) + + session$sendCustomMessage( + "shinyReactMessage", + list( + type = namespaced_type, + data = data + ) + ) +} diff --git a/examples/shiny-react-upstream/1-hello-world/srcts/HelloWorldComponent.tsx b/examples/shiny-react-upstream/1-hello-world/srcts/HelloWorldComponent.tsx new file mode 100644 index 00000000..ac4d9e20 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/srcts/HelloWorldComponent.tsx @@ -0,0 +1,34 @@ +import { useShinyInput, useShinyOutput } from "@posit/shiny-react"; +import React from "react"; + +function HelloWorldComponent() { + const [txtin, setTxtin] = useShinyInput