diff --git a/.gitignore b/.gitignore index a955019a..81a9cdca 100644 --- a/.gitignore +++ b/.gitignore @@ -211,3 +211,6 @@ __marimo__/ # Node node_modules/ + +# Built assets in shiny-react upstream examples +examples/shiny-react-upstream/*/www/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22e81d36..cc864f0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,9 @@ repos: rev: v6.0.0 hooks: - id: trailing-whitespace + exclude: ^(js/dist/|pkg-py/src/shinyjson/www/|pkg-r/inst/lib/shiny/) - id: end-of-file-fixer + exclude: ^(js/dist/|pkg-py/src/shinyjson/www/|pkg-r/inst/lib/shiny/) - id: check-yaml - id: check-merge-conflict - id: check-added-large-files diff --git a/Makefile b/Makefile index 35607130..4fb1dcc1 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,11 @@ js-build-watch: ## [js] Build JS code in watch mode @echo "๐Ÿงณ Building JS code in watch mode" cd $(PATH_PKG_JS) && npm run watch +.PHONY: js-test +js-test: ## [js] Run JS tests + @echo "๐Ÿงช Running JS tests" + cd $(PATH_PKG_JS) && npm test + .PHONY: update-dist update-dist: js-build r-update-dist py-update-dist ## Update shinyjson web assets in all packages diff --git a/docs/plans/2026-03-18-shiny-react-modules-design.md b/docs/plans/2026-03-18-shiny-react-modules-design.md new file mode 100644 index 00000000..acdc8c04 --- /dev/null +++ b/docs/plans/2026-03-18-shiny-react-modules-design.md @@ -0,0 +1,110 @@ +# Integrate shiny-react module namespace support + +**Date:** 2026-03-18 +**Source PR:** https://github.com/wch/shiny-react/pull/3 +**Status:** Approved + +## Goal + +Integrate `wch/shiny-react#3` (Shiny module namespace support) into shinyjson's vendored copy and bring the upstream examples as reference material. + +## Context + +shinyjson vendors `@posit/shiny-react` at `js/src/shiny-react/`. The upstream PR adds module namespace support so that multiple instances of the same React widget can coexist on a page without ID conflicts, matching Shiny's server-side `moduleServer` / `@module.server` pattern. + +## Parts + +### 1. Vendored shiny-react source changes + +**New files in `js/src/shiny-react/`:** + +- `ShinyModuleContext.tsx` โ€” `ShinyModuleProvider` (React context provider), `useShinyModuleNamespace()` hook, `applyNamespace(id, namespace)` utility +- `ShinyReactComponentElement.tsx` โ€” Base `HTMLElement` subclass for custom web elements that render React components. Handles slot preservation, `data-*` to props parsing, auto-`ShinyModuleProvider` wrapping, and Shiny `bindAll`/`unbindAll` lifecycle. + +**Modified files in `js/src/shiny-react/`:** + +- `use-shiny.ts` โ€” `useShinyInput`, `useShinyOutput`, `useShinyMessageHandler` each gain an optional `namespace` in their options. Auto-reads from `ShinyModuleContext` if no explicit namespace. Uses `namespacedId` for all registry lookups and effect deps. +- `ImageOutput.tsx` โ€” Same namespace pattern applied to the `id` prop and `clientdata_output_*` input IDs. +- `index.ts` โ€” Adds exports: `ShinyModuleProvider`, `useShinyModuleNamespace`, `ShinyReactComponentElement`. + +### 2. Python `post_message` fix + +In `pkg-py/src/shinyjson/_post_message.py`, wrap the message type in `shiny.module.resolve_id()` so that messages sent inside a Shiny module are correctly namespaced. This is a one-liner and is backwards-compatible (`resolve_id` is a no-op outside module context). + +### 3. Examples + +Copy all content from the PR branch into `examples/shiny-react-upstream/`: + +``` +examples/shiny-react-upstream/ + README.md # shiny-react README with PR changes + 1-hello-world/ + 2-inputs/ + 3-outputs/ + 4-messages/ + 5-shadcn/ + 6-dashboard/ + 7-chat/ + 8-modules/ # New in PR + 9-blended/ # New in PR +``` + +Add `examples/shiny-react-upstream/*/www/` to `.gitignore`. + +These are verbatim upstream copies. They use `@posit/shiny-react` as a local `file:` dependency and the copy-paste `shinyreact.py`/`shinyreact.R` helper pattern. They will not run as-is within shinyjson. They serve as reference material for future adaptation. + +### 4. Build and export wiring + +**`js/src/index.ts` global API additions:** + +Expose on `window.shinyjson`: +- `ShinyModuleProvider` โ€” downstream IIFE bundles wrap components in namespace providers +- `ShinyReactComponentElement` โ€” downstream packages extend the base custom element class + +**Build verification:** +1. `make js-build` โ€” TypeScript compiles, Vite bundles +2. `make update-dist` โ€” copy built assets to `pkg-py/` and `pkg-r/` +3. `make py-check` โ€” Python type checking and tests pass + +### 5. JavaScript unit tests + +**Test framework:** Vitest + jsdom + `@testing-library/react` + +- Vitest because the project already uses Vite +- jsdom for DOM environment (spec-compliant custom element support matters here) +- `@testing-library/react` for `renderHook()` and React component tests + +**Dev dependencies to add in `js/package.json`:** +- `vitest` +- `@testing-library/react` (pulls in `@testing-library/dom`) + +**Makefile target:** `make js-test` + +**What to test:** + +`applyNamespace` (pure function): +- `applyNamespace("count", "mod1")` returns `"mod1-count"` +- `applyNamespace("count", null)` returns `"count"` + +`ShinyModuleContext` (React context): +- `ShinyModuleProvider` sets namespace, `useShinyModuleNamespace()` reads it +- Nested providers: inner namespace wins +- No provider: `useShinyModuleNamespace()` returns `null` + +`use-shiny.ts` hooks (namespace integration): +- `useShinyInput` with explicit `namespace` option uses `namespace-id` +- `useShinyInput` inside `ShinyModuleProvider` auto-namespaces +- Explicit `namespace` overrides provider context +- Same patterns for `useShinyOutput` and `useShinyMessageHandler` + +`ShinyReactComponentElement` (custom element, plain DOM tests): +- `getConfig()` parses `data-*` attributes (strings and JSON auto-parse) +- Element with `id` attribute wraps in `ShinyModuleProvider` +- Element without `id` renders without namespace wrapper +- Slot collection from `data-slot` children + +## Decisions + +- **Approach A chosen** over full upstream mirror (B) and pre-adapted examples (C). Keeps integration clean and scoped; examples land as reference for separate follow-up work. +- **jsdom over happy-dom** โ€” small test suite where speed doesn't matter, but custom element spec compliance does. +- **`@testing-library/react` for hooks, plain DOM for custom elements** โ€” right tool for each test category. diff --git a/docs/plans/2026-03-18-shiny-react-modules.md b/docs/plans/2026-03-18-shiny-react-modules.md new file mode 100644 index 00000000..f9766ef0 --- /dev/null +++ b/docs/plans/2026-03-18-shiny-react-modules.md @@ -0,0 +1,940 @@ +# Shiny-React Module Namespace Support โ€” Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Integrate `wch/shiny-react#3` module namespace support into shinyjson's vendored copy, fix Python `post_message` namespacing, add upstream examples as reference material, and add JavaScript unit tests. + +**Architecture:** Copy two new files (`ShinyModuleContext.tsx`, `ShinyReactComponentElement.tsx`) into the vendored `js/src/shiny-react/` directory. Apply namespace diffs to `use-shiny.ts`, `ImageOutput.tsx`, and `index.ts`. Expose new exports on `window.shinyjson`. Add Vitest + jsdom + Testing Library for JS tests. Copy upstream examples into `examples/shiny-react-upstream/`. + +**Tech Stack:** TypeScript, React 19, Vitest, jsdom, @testing-library/react, Python (shiny.module.resolve_id) + +--- + +### Task 1: Add Vitest test infrastructure + +**Files:** +- Modify: `js/package.json` +- Create: `js/vitest.config.ts` +- Modify: `Makefile` + +**Step 1: Add Vitest dev dependencies** + +In `js/package.json`, add to `devDependencies`: + +```json +"vitest": "^3.2.1", +"jsdom": "^26.1.0", +"@testing-library/react": "^16.3.0" +``` + +**Step 2: Create Vitest config** + +Create `js/vitest.config.ts`: + +```typescript +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + }, +}); +``` + +**Step 3: Add test script to package.json** + +In `js/package.json`, add to `scripts`: + +```json +"test": "vitest run" +``` + +**Step 4: Add Makefile target** + +In `Makefile`, after the `js-build-watch` target (line 53), add: + +```makefile +.PHONY: js-test +js-test: ## [js] Run JS tests + @echo "๐Ÿงช Running JS tests" + cd $(PATH_PKG_JS) && npm test +``` + +**Step 5: Install dependencies** + +Run: `cd js && npm install` +Expected: package-lock.json updated, node_modules updated + +**Step 6: Verify test runner works** + +Run: `cd js && npx vitest run` +Expected: "No test files found" (no tests yet โ€” confirms vitest is working) + +**Step 7: Commit** + +```bash +git add js/package.json js/package-lock.json js/vitest.config.ts Makefile +git commit -m "chore: add Vitest test infrastructure with jsdom and Testing Library" +``` + +--- + +### Task 2: Add `ShinyModuleContext.tsx` with tests + +**Files:** +- Create: `js/src/shiny-react/ShinyModuleContext.tsx` +- Create: `js/src/shiny-react/__tests__/ShinyModuleContext.test.tsx` + +**Step 1: Write the tests** + +Create `js/src/shiny-react/__tests__/ShinyModuleContext.test.tsx`: + +```typescript +import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { + applyNamespace, + ShinyModuleProvider, + useShinyModuleNamespace, +} from "../ShinyModuleContext"; + +describe("applyNamespace", () => { + it("prefixes id with namespace when namespace is provided", () => { + expect(applyNamespace("count", "mod1")).toBe("mod1-count"); + }); + + it("returns raw id when namespace is null", () => { + expect(applyNamespace("count", null)).toBe("count"); + }); + + it("returns raw id when namespace is empty string", () => { + expect(applyNamespace("count", "")).toBe("count"); + }); +}); + +describe("ShinyModuleProvider / useShinyModuleNamespace", () => { + it("returns null when not inside a provider", () => { + const { result } = renderHook(() => useShinyModuleNamespace()); + expect(result.current).toBeNull(); + }); + + it("returns the namespace from the provider", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useShinyModuleNamespace(), { wrapper }); + expect(result.current).toBe("myModule"); + }); + + it("inner provider overrides outer provider", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useShinyModuleNamespace(), { wrapper }); + expect(result.current).toBe("inner"); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd js && npx vitest run` +Expected: FAIL โ€” `ShinyModuleContext` module not found + +**Step 3: Create `ShinyModuleContext.tsx`** + +Create `js/src/shiny-react/ShinyModuleContext.tsx`: + +```tsx +import { createContext, useContext, type ReactNode } from "react"; + +const ShinyModuleContext = createContext(null); + +export interface ShinyModuleProviderProps { + namespace: string; + children: ReactNode; +} + +/** + * Provides a namespace context for Shiny module support. + * + * All child components using useShinyInput, useShinyOutput, or + * useShinyMessageHandler will automatically have their IDs prefixed + * with the provided namespace. + * + * Note: This provider does NOT support nesting. If you need nested modules, + * pass the full namespace string (e.g., "outer-inner") directly. + * + * @param namespace The complete namespace string to apply to child hooks. + * @param children React children that will receive the namespace context. + * + * @example + * ```tsx + * + * {/* useShinyInput("x") becomes "myModule-x" *} + * + * ``` + */ +export function ShinyModuleProvider({ + namespace, + children, +}: ShinyModuleProviderProps) { + return ( + + {children} + + ); +} + +/** + * Hook to access the current module namespace from context. + * Returns null if not within a ShinyModuleProvider. + */ +export function useShinyModuleNamespace(): string | null { + return useContext(ShinyModuleContext); +} + +/** + * Utility function to apply namespace to an ID. + * If namespace is provided, returns `${namespace}-${id}`. + * Otherwise returns the original id. + */ +export function applyNamespace(id: string, namespace: string | null): string { + if (namespace) { + return `${namespace}-${id}`; + } + return id; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd js && npx vitest run` +Expected: All 5 tests PASS + +**Step 5: Commit** + +```bash +git add js/src/shiny-react/ShinyModuleContext.tsx js/src/shiny-react/__tests__/ShinyModuleContext.test.tsx +git commit -m "feat: add ShinyModuleContext with provider, hook, and applyNamespace" +``` + +--- + +### Task 3: Add namespace support to `use-shiny.ts` with tests + +**Files:** +- Modify: `js/src/shiny-react/use-shiny.ts` +- Create: `js/src/shiny-react/__tests__/use-shiny-namespace.test.tsx` + +**Step 1: Write the tests** + +Create `js/src/shiny-react/__tests__/use-shiny-namespace.test.tsx`: + +```typescript +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { ShinyModuleProvider } from "../ShinyModuleContext"; + +// Mock the registries and Shiny initialization so hooks can run +// without a real Shiny environment +vi.mock("../get-shiny", () => ({ + getShiny: () => undefined, +})); + +vi.mock("../react-registry", () => { + const inputs = { + get: vi.fn(() => null), + getOrCreate: vi.fn(() => ({ + updateDebounceDelay: vi.fn(), + updatePriority: vi.fn(), + addUseStateSetValueFn: vi.fn(), + removeUseStateSetValueFn: vi.fn(), + getValue: vi.fn(() => null), + setValue: vi.fn(), + })), + }; + const outputs = { + add: vi.fn(), + remove: vi.fn(), + }; + return { + initializeReactRegistry: vi.fn(), + getReactRegistry: vi.fn(() => ({ inputs, outputs })), + __inputs: inputs, + __outputs: outputs, + }; +}); + +vi.mock("../output-registry", () => ({ + createReactOutputBinding: vi.fn(), +})); + +vi.mock("../message-registry", () => ({ + initializeMessageRegistry: vi.fn(), +})); + +import { useShinyInput, useShinyOutput } from "../use-shiny"; +import { getReactRegistry } from "../react-registry"; + +describe("useShinyInput namespace", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses plain id when no namespace is provided", () => { + renderHook(() => useShinyInput("count", 0)); + const registry = getReactRegistry(); + expect(registry.inputs.getOrCreate).toHaveBeenCalledWith("count", 0); + }); + + it("uses explicit namespace option", () => { + renderHook(() => + useShinyInput("count", 0, { namespace: "mod1" }), + ); + const registry = getReactRegistry(); + expect(registry.inputs.getOrCreate).toHaveBeenCalledWith("mod1-count", 0); + }); + + it("uses namespace from ShinyModuleProvider context", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + renderHook(() => useShinyInput("count", 0), { wrapper }); + const registry = getReactRegistry(); + expect(registry.inputs.getOrCreate).toHaveBeenCalledWith("ctxMod-count", 0); + }); + + it("explicit namespace overrides context namespace", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + renderHook(() => useShinyInput("count", 0, { namespace: "explicit" }), { + wrapper, + }); + const registry = getReactRegistry(); + expect(registry.inputs.getOrCreate).toHaveBeenCalledWith( + "explicit-count", + 0, + ); + }); +}); + +describe("useShinyOutput namespace", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses plain outputId when no namespace is provided", () => { + renderHook(() => useShinyOutput("result")); + // Note: outputs.add is only called after shiny is initialized, + // which won't happen in our mock. We verify via the registry lookup id. + const registry = getReactRegistry(); + // The hook should have been created without error + expect(registry).toBeDefined(); + }); + + it("uses explicit namespace option for output", () => { + renderHook(() => + useShinyOutput("result", undefined, { namespace: "mod1" }), + ); + const registry = getReactRegistry(); + expect(registry).toBeDefined(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd js && npx vitest run` +Expected: FAIL โ€” `useShinyInput` doesn't accept `namespace` option yet + +**Step 3: Apply namespace changes to `use-shiny.ts`** + +Modify `js/src/shiny-react/use-shiny.ts`: + +Add import at line 10 (after existing imports): + +```typescript +import { + applyNamespace, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; +``` + +**`useShinyInput`** โ€” modify the function signature and body: + +At line 43-49, add `namespace` to the options destructure: + +```typescript +export function useShinyInput( + id: string, + defaultValue: T, + { + debounceMs = 100, + priority, + namespace: explicitNamespace, + }: { + debounceMs?: number; + priority?: EventPriority; + namespace?: string; + } = {}, +): [T, (value: T) => void] { + ensureShinyReactInitialized(); + + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedId = applyNamespace(id, namespace); +``` + +Then replace all occurrences of `id` used for registry lookups: +- Line 62-63: `reactRegistry.inputs.get(id,)` โ†’ `reactRegistry.inputs.get(namespacedId,)` +- Line 87-88: `reactRegistry.inputs.getOrCreate(id,` โ†’ `reactRegistry.inputs.getOrCreate(namespacedId,` +- Line 112: deps array `[id, shinyInitialized,` โ†’ `[namespacedId, shinyInitialized,` +- Line 121: `reactRegistry.inputs.get(id)` โ†’ `reactRegistry.inputs.get(namespacedId)` +- Line 128: deps `[id]` โ†’ `[namespacedId, id]` + +**`useShinyOutput`** โ€” add namespace option as third parameter: + +```typescript +export function useShinyOutput( + outputId: string, + defaultValue: T | undefined = undefined, + { + namespace: explicitNamespace, + }: { + namespace?: string; + } = {}, +): [T | undefined, boolean] { +``` + +After `ensureShinyReactInitialized();`, add: + +```typescript + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedOutputId = applyNamespace(outputId, namespace); +``` + +Replace registry calls: +- `reactRegistry.outputs.add(outputId,` โ†’ `reactRegistry.outputs.add(namespacedOutputId,` +- `reactRegistry.outputs.remove(outputId)` โ†’ `reactRegistry.outputs.remove(namespacedOutputId)` +- deps: `[outputId, shinyInitialized]` โ†’ `[namespacedOutputId, shinyInitialized]` + +**`useShinyMessageHandler`** โ€” add namespace option as third parameter: + +```typescript +export function useShinyMessageHandler( + messageType: string, + handler: (data: T) => void, + { + namespace: explicitNamespace, + }: { + namespace?: string; + } = {}, +): void { +``` + +After `ensureShinyReactInitialized();`, add: + +```typescript + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedMessageType = applyNamespace(messageType, namespace); +``` + +Replace: +- `if (!shinyInitialized || !messageType` โ†’ `if (!shinyInitialized || !namespacedMessageType` +- `shiny.messageRegistry.addHandler(messageType,` โ†’ `shiny.messageRegistry.addHandler(namespacedMessageType,` +- `shiny.messageRegistry.removeHandler(messageType,` โ†’ `shiny.messageRegistry.removeHandler(namespacedMessageType,` +- deps: `[shinyInitialized, messageType, handler]` โ†’ `[shinyInitialized, namespacedMessageType, handler]` + +**Step 4: Run tests to verify they pass** + +Run: `cd js && npx vitest run` +Expected: All tests PASS + +**Step 5: Verify TypeScript compiles** + +Run: `cd js && npx tsc --noEmit` +Expected: No errors + +**Step 6: Commit** + +```bash +git add js/src/shiny-react/use-shiny.ts js/src/shiny-react/__tests__/use-shiny-namespace.test.tsx +git commit -m "feat: add namespace support to useShinyInput, useShinyOutput, useShinyMessageHandler" +``` + +--- + +### Task 4: Add namespace support to `ImageOutput.tsx` + +**Files:** +- Modify: `js/src/shiny-react/ImageOutput.tsx` + +**Step 1: Apply namespace changes to ImageOutput** + +At the top of `js/src/shiny-react/ImageOutput.tsx`, after line 3, add: + +```typescript +import { + applyNamespace, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; +``` + +Add `namespace` prop and jsdoc at line 138 (in the props type), add: + +```typescript + namespace: explicitNamespace, +``` + +And in the type definition add: + +```typescript + namespace?: string; +``` + +At the start of the function body (after the opening `{`), add: + +```typescript + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedId = applyNamespace(id, namespace); +``` + +Replace the clientdata input IDs: +- `".clientdata_output_" + id + "_width"` โ†’ `` `.clientdata_output_${namespacedId}_width` `` +- `".clientdata_output_" + id + "_height"` โ†’ `` `.clientdata_output_${namespacedId}_height` `` +- `".clientdata_output_" + id + "_hidden"` โ†’ `` `.clientdata_output_${namespacedId}_hidden` `` +- `useShinyOutput(id,` โ†’ `useShinyOutput(namespacedId,` + +**Step 2: Verify TypeScript compiles** + +Run: `cd js && npx tsc --noEmit` +Expected: No errors + +**Step 3: Commit** + +```bash +git add js/src/shiny-react/ImageOutput.tsx +git commit -m "feat: add namespace support to ImageOutput component" +``` + +--- + +### Task 5: Update shiny-react `index.ts` exports + +**Files:** +- Modify: `js/src/shiny-react/index.ts` + +**Step 1: Add new exports** + +In `js/src/shiny-react/index.ts`, after line 6 (`export { ImageOutput }`), add: + +```typescript +export { ShinyReactComponentElement } from "./ShinyReactComponentElement"; +``` + +After line 12 (after the `use-shiny` export block), add: + +```typescript +export { + ShinyModuleProvider, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; +``` + +**Step 2: Verify TypeScript compiles** + +Run: `cd js && npx tsc --noEmit` +Expected: Will fail because `ShinyReactComponentElement.tsx` doesn't exist yet. That's OK โ€” skip to Task 6, then come back. + +**Step 3: Commit** (defer until after Task 6) + +--- + +### Task 6: Add `ShinyReactComponentElement.tsx` with tests + +**Files:** +- Create: `js/src/shiny-react/ShinyReactComponentElement.tsx` +- Create: `js/src/shiny-react/__tests__/ShinyReactComponentElement.test.tsx` + +**Step 1: Write the tests** + +Create `js/src/shiny-react/__tests__/ShinyReactComponentElement.test.tsx`: + +```typescript +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { ShinyReactComponentElement } from "../ShinyReactComponentElement"; + +// Minimal Shiny mock on window +beforeEach(() => { + (window as any).Shiny = { + bindAll: vi.fn(), + unbindAll: vi.fn(), + }; +}); + +afterEach(() => { + delete (window as any).Shiny; +}); + +describe("ShinyReactComponentElement", () => { + describe("getConfig", () => { + it("parses data-* attributes into a config object", () => { + const el = new ShinyReactComponentElement(); + el.setAttribute("data-count", "5"); + el.setAttribute("data-title", "Hello"); + el.setAttribute("data-enabled", "true"); + el.setAttribute("data-items", "[1,2,3]"); + + // Access protected method via type cast + const config = (el as any).getConfig(); + + expect(config.count).toBe(5); + expect(config.title).toBe("Hello"); + expect(config.enabled).toBe(true); + expect(config.items).toEqual([1, 2, 3]); + }); + + it("falls back to string for non-JSON values", () => { + const el = new ShinyReactComponentElement(); + el.setAttribute("data-label", "not json"); + + const config = (el as any).getConfig(); + expect(config.label).toBe("not json"); + }); + }); + + describe("namespace", () => { + it("returns undefined when no id is set", () => { + const el = new ShinyReactComponentElement(); + expect((el as any).namespace).toBeUndefined(); + }); + + it("returns the element id as namespace", () => { + const el = new ShinyReactComponentElement(); + el.id = "counter1"; + expect((el as any).namespace).toBe("counter1"); + }); + }); + + describe("captureSlots", () => { + it("captures children as __children__ when no data-slot elements exist", () => { + const el = new ShinyReactComponentElement(); + const child = document.createElement("div"); + child.textContent = "hello"; + el.appendChild(child); + + const slots = (el as any).captureSlots(); + expect(slots.has("__children__")).toBe(true); + expect(slots.get("__children__")).toHaveLength(1); + }); + + it("captures named slots from data-slot attributes", () => { + const el = new ShinyReactComponentElement(); + const sidebar = document.createElement("div"); + sidebar.setAttribute("data-slot", "sidebar"); + sidebar.innerHTML = "

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("txtin", "Hello, world!"); + + const [txtout, _] = useShinyOutput("txtout", undefined); + + const handleInputChange = (event: React.ChangeEvent) => { + setTxtin(event.target.value); + }; + + return ( +
+

Hello Shiny React!

+
+ + +
+
+
+ +
{txtout}
+
+
+ ); +} + +export default HelloWorldComponent; diff --git a/examples/shiny-react-upstream/1-hello-world/srcts/main.tsx b/examples/shiny-react-upstream/1-hello-world/srcts/main.tsx new file mode 100644 index 00000000..298b0464 --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/srcts/main.tsx @@ -0,0 +1,11 @@ +import { createRoot } from "react-dom/client"; +import HelloWorldComponent from "./HelloWorldComponent"; +import "./styles.css"; + +const container = document.getElementById("root"); +if (container) { + const root = createRoot(container); + root.render(); +} else { + console.error("Could not find root element to mount React component."); +} diff --git a/examples/shiny-react-upstream/1-hello-world/srcts/styles.css b/examples/shiny-react-upstream/1-hello-world/srcts/styles.css new file mode 100644 index 00000000..7d43f78a --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/srcts/styles.css @@ -0,0 +1,184 @@ +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + margin: 0; + padding: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + /* Safari-compatible gradient */ + background: -webkit-linear-gradient( + 135deg, + #667eea 0%, + #764ba2 50%, + #f093fb 100% + ); + background-attachment: fixed; /* Ensures gradient stays fixed during scroll in Safari */ + min-height: 100dvh; + color: #1a202c; + position: relative; +} + +body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient( + circle at 20% 50%, + rgba(120, 119, 198, 0.3), + transparent 50% + ), + radial-gradient( + circle at 80% 20%, + rgba(255, 119, 198, 0.3), + transparent 50% + ), + radial-gradient( + circle at 40% 80%, + rgba(120, 219, 255, 0.3), + transparent 50% + ); + pointer-events: none; + z-index: -1; +} + +#root { + min-height: calc(100dvh - 32px); + display: flex; + align-items: center; + justify-content: center; +} + +.card { + width: 480px; + max-width: 90vw; + margin: 0 auto; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + padding: 40px; + border-radius: 24px; + box-shadow: + 0 25px 50px rgba(0, 0, 0, 0.15), + 0 8px 32px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + border: 1px solid rgba(255, 255, 255, 0.3); + animation: slideIn 0.8s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +h1 { + margin: 0 0 2rem 0; + text-align: center; + font-size: 3rem; + font-weight: 700; + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; +} + +label { + color: #4a5568; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 12px; + display: block; + letter-spacing: 0.025em; +} + +.input-group { + margin-bottom: 32px; + position: relative; +} + +input { + width: 100%; + padding: 16px 20px; + margin: 0; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 16px; + box-sizing: border-box; + font-size: 16px; + font-family: inherit; + font-weight: 500; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +input:focus { + outline: none; + border-color: rgba(102, 126, 234, 0.6); + background: rgba(255, 255, 255, 0.95); + box-shadow: + 0 8px 25px rgba(102, 126, 234, 0.2), + 0 4px 12px rgba(0, 0, 0, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + transform: translateY(-2px); +} + +input:hover:not(:focus) { + border-color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.9); + transform: translateY(-1px); +} + +input::placeholder { + color: #a0aec0; + font-weight: 400; +} + +hr { + border: none; + height: 2px; + background: linear-gradient(90deg, transparent, #e2e8f0, transparent); + margin: 24px 0; +} + +.output-section { + margin-top: 24px; +} + +.output-label { + color: #4a5568; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 12px; + display: block; + letter-spacing: 0.025em; +} + +.output-content { + text-align: center; + font-size: 3rem; + font-weight: 700; + background: linear-gradient( + 135deg, + #ff6b6b 0%, + #f29223 25%, + #45b7d1 50%, + #96ceb4 75%, + #feca57 100% + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; +} diff --git a/examples/shiny-react-upstream/1-hello-world/tsconfig.json b/examples/shiny-react-upstream/1-hello-world/tsconfig.json new file mode 100644 index 00000000..d788f15b --- /dev/null +++ b/examples/shiny-react-upstream/1-hello-world/tsconfig.json @@ -0,0 +1,19 @@ +{ + "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"] +} diff --git a/examples/shiny-react-upstream/2-inputs/.gitignore b/examples/shiny-react-upstream/2-inputs/.gitignore new file mode 100644 index 00000000..90331847 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +r/www/ +py/www/ diff --git a/examples/shiny-react-upstream/2-inputs/README.md b/examples/shiny-react-upstream/2-inputs/README.md new file mode 100644 index 00000000..30883162 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/README.md @@ -0,0 +1,93 @@ +# Input Examples + +This example demonstrates various input components using the shiny-react library, showcasing different ways 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. Each component demonstrates a different input type, with the Shiny back end echoing back the received values (with minimal transformation). + +## 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 + - `App.tsx` - Main App component that displays all input examples + - `InputOutputCard.tsx` - Reusable card wrapper for input/output pairs + - `Card.tsx` - Basic card component with title + - Input components: + - `TextInputCard.tsx` - Text input (uppercased by server) + - `NumberInputCard.tsx` - Number input with min/max/step + - `CheckboxInputCard.tsx` - Boolean checkbox input + - `RadioInputCard.tsx` - Radio button group selection + - `SelectInputCard.tsx` - Dropdown select input + - `SliderInputCard.tsx` - Range slider input + - `DateInputCard.tsx` - HTML5 date picker input + - `ButtonInputCard.tsx` - Button that sends incremental counter values + - `styles.css` - Comprehensive CSS styling with responsive design +- **`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. + +## Input Components Demonstrated + +This example showcases eight different input types and how they integrate with Shiny: + +1. **Text Input** - Basic text input that gets uppercased by the server +2. **Number Input** - Numeric input with range constraints (0-100) +3. **Checkbox Input** - Boolean checkbox for true/false values +4. **Radio Button Input** - Single selection from multiple options +5. **Select Input** - Dropdown selection from a list of choices +6. **Slider Input** - Range slider for numeric values with visual feedback +7. **Date Input** - HTML5 date picker for date selection +8. **Button Input** - Click counter that increments on each button press + +Each component follows the same pattern: +- Uses `useShinyInput` to send data to Shiny server +- Uses `useShinyOutput` to receive processed data back from server +- Displays both the input value and server response side by side +- Demonstrates real-time bidirectional communication diff --git a/examples/shiny-react-upstream/2-inputs/package.json b/examples/shiny-react-upstream/2-inputs/package.json new file mode 100644 index 00000000..287a8c14 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/package.json @@ -0,0 +1,42 @@ +{ + "private": true, + "name": "shiny-react-inputs", + "version": "1.0.0", + "type": "module", + "description": "Inputs example 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 --outfile=r/www/main.js --sourcemap --format=esm --alias:react=react --watch", + "watch-py": "esbuild srcts/main.tsx --bundle --outfile=py/www/main.js --sourcemap --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": "Input Components", + "description": "Comprehensive showcase of input components and form handling", + "deployToShinylive": true, + "comment": "" + } +} diff --git a/examples/shiny-react-upstream/2-inputs/py/app.py b/examples/shiny-react-upstream/2-inputs/py/app.py new file mode 100644 index 00000000..4d150c30 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/py/app.py @@ -0,0 +1,66 @@ +import datetime +from pathlib import Path +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 txtout(): + return input.txtin().upper() + + @render_json + def numberout(): + return str(input.numberin()) + + @render_json + def checkboxout(): + return str(input.checkboxin()) + + @render_json + def radioout(): + return str(input.radioin()) + + @render_json + def selectout(): + return str(input.selectin()) + + @render_json + def sliderout(): + return str(input.sliderin()) + + @render_json + def dateout(): + return str(input.datein()) + + # Track number of button clicks + num_button_clicks = 0 + + @render_json + def buttonout(): + if input.buttonin() is None: + return None + nonlocal num_button_clicks + num_button_clicks += 1 + return str(num_button_clicks) + + @render_json + def fileout(): + return input.filein() + + @render_json + def batchout(): + data = input.batchdata() + if data is None: + return "No data submitted yet." + + data["receivedAt"] = datetime.datetime.now().isoformat() + + return data + + +app = App( + page_react(title="Inputs - Shiny React"), + server, + static_assets=str(Path(__file__).parent / "www"), +) diff --git a/examples/shiny-react-upstream/2-inputs/py/shinyreact.py b/examples/shiny-react-upstream/2-inputs/py/shinyreact.py new file mode 100644 index 00000000..fe824759 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/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/2-inputs/r/app.R b/examples/shiny-react-upstream/2-inputs/r/app.R new file mode 100644 index 00000000..8f0c8136 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/r/app.R @@ -0,0 +1,58 @@ +library(shiny) + +source("shinyreact.R", local = TRUE) + +server <- function(input, output, session) { + output$txtout <- render_json({ + toupper(input$txtin) + }) + + output$numberout <- render_json({ + input$numberin + }) + + output$checkboxout <- render_json({ + as.character(input$checkboxin) + }) + + output$radioout <- render_json({ + input$radioin + }) + + output$selectout <- render_json({ + input$selectin + }) + + output$sliderout <- render_json({ + input$sliderin + }) + + output$dateout <- render_json({ + input$datein + }) + + num_button_clicks <- 0 + output$buttonout <- render_json({ + # Take a reactive dependency on the button, and ignore starting null value + if (is.null(input$buttonin)) { + return() + } + num_button_clicks <<- num_button_clicks + 1 + num_button_clicks + }) + + output$fileout <- render_json({ + input$filein + }) + + output$batchout <- render_json({ + data <- input$batchdata + if (is.null(data)) { + return("No data submitted yet.") + } + data$receivedAt <- as.character(Sys.time()) + data + }) +} + +shinyApp(ui = page_react(title = "Inputs - Shiny React"), server = server) diff --git a/examples/shiny-react-upstream/2-inputs/r/shinyreact.R b/examples/shiny-react-upstream/2-inputs/r/shinyreact.R new file mode 100644 index 00000000..a9e99807 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/r/shinyreact.R @@ -0,0 +1,84 @@ +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) { + # 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/2-inputs/srcts/App.tsx b/examples/shiny-react-upstream/2-inputs/srcts/App.tsx new file mode 100644 index 00000000..ae1973d8 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/srcts/App.tsx @@ -0,0 +1,32 @@ +import BatchFormCard from "./BatchFormCard"; +import ButtonInputCard from "./ButtonInputCard"; +import CheckboxInputCard from "./CheckboxInputCard"; +import DateInputCard from "./DateInputCard"; +import FileInputCard from "./FileInputCard"; +import NumberInputCard from "./NumberInputCard"; +import RadioInputCard from "./RadioInputCard"; +import SelectInputCard from "./SelectInputCard"; +import SliderInputCard from "./SliderInputCard"; +import TextInputCard from "./TextInputCard"; + +function App() { + return ( +
+

Shiny React Input Examples

+
+ + + + + + + + + + +
+
+ ); +} + +export default App; diff --git a/examples/shiny-react-upstream/2-inputs/srcts/BatchFormCard.tsx b/examples/shiny-react-upstream/2-inputs/srcts/BatchFormCard.tsx new file mode 100644 index 00000000..e576cd73 --- /dev/null +++ b/examples/shiny-react-upstream/2-inputs/srcts/BatchFormCard.tsx @@ -0,0 +1,126 @@ +import { useShinyInput, useShinyOutput } from "@posit/shiny-react"; +import React, { useState } from "react"; +import InputOutputCard from "./InputOutputCard"; + +function BatchFormCard() { + // Local state (NOT Shiny inputs) + const [comment, setComment] = useState(""); + const [priority, setPriority] = useState(50); + const [features, setFeatures] = useState({ + authentication: false, + notifications: false, + darkMode: false, + analytics: false, + }); + + // Shiny input/output for batch submission + const [batchData, setBatchData] = useShinyInput( + "batchdata", + null, + { + // This makes it so there's no delay to sending the value when the button + // is clicked. + debounceMs: 0, + // This makes it so that even if the input value is the same as the + // previous, it will still cause invalidation of reactive functions on the + // server. + priority: "event", + }, + ); + const [batchOutput, _] = useShinyOutput("batchout", ""); + + const handleFeatureChange = (feature: keyof typeof features) => { + setFeatures((prev) => ({ + ...prev, + [feature]: !prev[feature], + })); + }; + + const handleSubmit = () => { + const formData = { + comment, + priority, + features, + }; + setBatchData(formData); + }; + + const selectedFeaturesCount = Object.values(features).filter(Boolean).length; + + const inputElement = ( +
+
+ +