From ae4a09b3d6fbe50fe2961e02398b263d8a9ca953 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:06:24 -0400 Subject: [PATCH 01/15] docs: design for integrating shiny-react module namespace support Covers vendored source changes from wch/shiny-react#3, Python post_message namespace fix, upstream examples as reference material, build/export wiring, and JavaScript unit test plan. Co-Authored-By: Claude Opus 4.6 --- .../2026-03-18-shiny-react-modules-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/plans/2026-03-18-shiny-react-modules-design.md 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. From 2b48e42437fc2f5f1de4618d5527954248eaf13f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:09:55 -0400 Subject: [PATCH 02/15] docs: implementation plan for shiny-react module namespace integration 10-task plan covering: Vitest infrastructure, ShinyModuleContext, use-shiny namespace support, ImageOutput update, ShinyReactComponentElement, global API wiring, Python post_message fix, upstream examples, and final build. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-18-shiny-react-modules.md | 940 +++++++++++++++++++ 1 file changed, 940 insertions(+) create mode 100644 docs/plans/2026-03-18-shiny-react-modules.md 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" +``` From 810d96b93e538199d73f862467eafc22106085e0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:17:06 -0400 Subject: [PATCH 03/15] chore: add Vitest test infrastructure with jsdom and Testing Library Co-Authored-By: Claude Opus 4.6 --- Makefile | 5 + js/package-lock.json | 1164 +++++++++++++++++++++++++++++++++++++++++- js/package.json | 8 +- js/vitest.config.ts | 9 + 4 files changed, 1183 insertions(+), 3 deletions(-) create mode 100644 js/vitest.config.ts 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/js/package-lock.json b/js/package-lock.json index 449687ae..2287f31a 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -15,13 +15,37 @@ }, "devDependencies": { "@posit/shiny": "^1.11.1", + "@testing-library/react": "^16.3.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^4.0.0", + "jsdom": "^26.1.0", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^3.2.1" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -256,6 +280,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -304,6 +338,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1163,6 +1312,63 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1228,6 +1434,17 @@ "@types/jquery": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/datatables.net": { "version": "1.10.28", "resolved": "https://registry.npmjs.org/@types/datatables.net/-/datatables.net-1.10.28.tgz", @@ -1238,6 +1455,13 @@ "@types/jquery": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1324,6 +1548,177 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -1371,6 +1766,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001776", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", @@ -1392,6 +1797,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1399,6 +1831,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1406,6 +1852,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1424,6 +1884,42 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -1431,6 +1927,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1480,6 +1996,44 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1505,6 +2059,67 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1512,6 +2127,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1572,6 +2227,13 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1582,6 +2244,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1615,6 +2298,43 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1622,6 +2342,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -1651,6 +2384,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -1672,6 +2431,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1727,6 +2494,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1743,6 +2537,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1753,6 +2554,154 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1858,6 +2807,219 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/js/package.json b/js/package.json index ce23320c..84ee216f 100644 --- a/js/package.json +++ b/js/package.json @@ -6,7 +6,8 @@ "scripts": { "build": "vite build", "watch": "vite build --watch", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@json-render/core": "^0.11.0", @@ -19,7 +20,10 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^4.0.0", + "@testing-library/react": "^16.3.0", + "jsdom": "^26.1.0", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^3.2.1" } } diff --git a/js/vitest.config.ts b/js/vitest.config.ts new file mode 100644 index 00000000..ef6ac31e --- /dev/null +++ b/js/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + }, +}); From a64e6840d24acba11d76f46eef262c56b75d3bd3 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:18:53 -0400 Subject: [PATCH 04/15] feat: add ShinyModuleContext with provider, hook, and applyNamespace Co-Authored-By: Claude Opus 4.6 --- js/src/shiny-react/ShinyModuleContext.tsx | 59 +++++++++++++++++++ .../__tests__/ShinyModuleContext.test.tsx | 47 +++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 js/src/shiny-react/ShinyModuleContext.tsx create mode 100644 js/src/shiny-react/__tests__/ShinyModuleContext.test.tsx diff --git a/js/src/shiny-react/ShinyModuleContext.tsx b/js/src/shiny-react/ShinyModuleContext.tsx new file mode 100644 index 00000000..afe0803b --- /dev/null +++ b/js/src/shiny-react/ShinyModuleContext.tsx @@ -0,0 +1,59 @@ +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 + * + * + * + * ``` + */ +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; +} diff --git a/js/src/shiny-react/__tests__/ShinyModuleContext.test.tsx b/js/src/shiny-react/__tests__/ShinyModuleContext.test.tsx new file mode 100644 index 00000000..f8f3b639 --- /dev/null +++ b/js/src/shiny-react/__tests__/ShinyModuleContext.test.tsx @@ -0,0 +1,47 @@ +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"); + }); +}); From ddea9f9c05baf6c1a69c4a650be58aba3d2b1b49 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:21:36 -0400 Subject: [PATCH 05/15] feat: add namespace support to useShinyInput, useShinyOutput, useShinyMessageHandler Each hook now accepts an optional `namespace` parameter and also reads from ShinyModuleProvider context. Explicit namespace takes precedence over context. IDs are prefixed via applyNamespace() before registry lookups. Includes tests covering plain, explicit, context, and override scenarios. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/use-shiny-namespace.test.tsx | 108 ++++++++++++++++++ js/src/shiny-react/use-shiny.ts | 57 ++++++--- 2 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 js/src/shiny-react/__tests__/use-shiny-namespace.test.tsx diff --git a/js/src/shiny-react/__tests__/use-shiny-namespace.test.tsx b/js/src/shiny-react/__tests__/use-shiny-namespace.test.tsx new file mode 100644 index 00000000..4375957f --- /dev/null +++ b/js/src/shiny-react/__tests__/use-shiny-namespace.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook } 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")); + const registry = getReactRegistry(); + expect(registry).toBeDefined(); + }); + + it("uses explicit namespace option for output", () => { + renderHook(() => + useShinyOutput("result", undefined, { namespace: "mod1" }), + ); + const registry = getReactRegistry(); + expect(registry).toBeDefined(); + }); +}); diff --git a/js/src/shiny-react/use-shiny.ts b/js/src/shiny-react/use-shiny.ts index b3a4d138..0d3915a6 100644 --- a/js/src/shiny-react/use-shiny.ts +++ b/js/src/shiny-react/use-shiny.ts @@ -7,6 +7,10 @@ import { type InputRegistryEntry } from "./input-registry"; import { initializeMessageRegistry } from "./message-registry"; import { createReactOutputBinding } from "./output-registry"; import { getReactRegistry, initializeReactRegistry } from "./react-registry"; +import { + applyNamespace, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; /** * A React hook for managing a Shiny input value. @@ -43,13 +47,20 @@ export function useShinyInput( { 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); + // NOTE: It's a little odd that debounceMs and priority passed this way; the // debounceMs is associated with the specific input name, and in Shiny's API, // priority is associated with each individual call to setInputValue(). But @@ -60,7 +71,7 @@ export function useShinyInput( let startValue: T = defaultValue; const reactRegistry = getReactRegistry(); const inputRegistryEntry = reactRegistry.inputs.get( - id, + namespacedId, ) as InputRegistryEntry; if (inputRegistryEntry) { @@ -85,7 +96,7 @@ export function useShinyInput( // Make sure the input registry entry exists for this Shiny input ID const reactRegistry = getReactRegistry(); const inputRegistryEntry = reactRegistry.inputs.getOrCreate( - id, + namespacedId, defaultValue, ); @@ -109,7 +120,7 @@ export function useShinyInput( // useEffect will be called again. If someone wants to really get rid of // the registry entry, they will have to do so manually. }; - }, [id, shinyInitialized, debounceMs, priority, defaultValue]); + }, [namespacedId, shinyInitialized, debounceMs, priority, defaultValue]); const setValueWrapped = useCallback( (value: T) => { @@ -118,14 +129,14 @@ export function useShinyInput( // } const reactRegistry = getReactRegistry(); - const inputRegistryEntry = reactRegistry.inputs.get(id); + const inputRegistryEntry = reactRegistry.inputs.get(namespacedId); if (!inputRegistryEntry) { - console.error(`Input ${id} not found`); + console.error(`Input ${namespacedId} not found`); return; } inputRegistryEntry.setValue(value); }, - [id], + [namespacedId, id], ); return [value, setValueWrapped]; @@ -147,6 +158,11 @@ export function useShinyInput( export function useShinyOutput( outputId: string, defaultValue: T | undefined = undefined, + { + namespace: explicitNamespace, + }: { + namespace?: string; + } = {}, ): [T | undefined, boolean] { const [value, setValue] = useState(defaultValue); const [recalculating, setRecalculating] = useState(false); @@ -154,17 +170,22 @@ export function useShinyOutput( ensureShinyReactInitialized(); + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedOutputId = applyNamespace(outputId, namespace); + useEffect(() => { if (!shinyInitialized) { return; } const reactRegistry = getReactRegistry(); - reactRegistry.outputs.add(outputId, setValue, setRecalculating); + reactRegistry.outputs.add(namespacedOutputId, setValue, setRecalculating); return () => { - reactRegistry.outputs.remove(outputId); + reactRegistry.outputs.remove(namespacedOutputId); }; - }, [outputId, shinyInitialized]); + }, [namespacedOutputId, shinyInitialized]); return [value, recalculating]; } @@ -195,13 +216,23 @@ export function useShinyOutput( export function useShinyMessageHandler( messageType: string, handler: (data: T) => void, + { + namespace: explicitNamespace, + }: { + namespace?: string; + } = {}, ): void { const shinyInitialized = useShinyInitialized(); ensureShinyReactInitialized(); + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedMessageType = applyNamespace(messageType, namespace); + useEffect(() => { - if (!shinyInitialized || !messageType || !handler) { + if (!shinyInitialized || !namespacedMessageType || !handler) { return; } const shiny = getShiny(); @@ -210,14 +241,14 @@ export function useShinyMessageHandler( } // Register the message handler with our dedicated message registry - shiny.messageRegistry.addHandler(messageType, handler); + shiny.messageRegistry.addHandler(namespacedMessageType, handler); // Cleanup function that removes the handler when component unmounts // or when messageType/handler changes return () => { - shiny.messageRegistry.removeHandler(messageType, handler); + shiny.messageRegistry.removeHandler(namespacedMessageType, handler); }; - }, [shinyInitialized, messageType, handler]); + }, [shinyInitialized, namespacedMessageType, handler]); } /** From 4848cbb3645726d8f04abbfbc73111d2cdc5576d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:25:19 -0400 Subject: [PATCH 06/15] feat: add namespace support to ImageOutput component Add namespace prop and context-based namespace resolution to ImageOutput. The component now applies namespaces to clientdata input IDs and the output binding, enabling proper Shiny module support. Co-Authored-By: Claude Opus 4.6 --- js/src/shiny-react/ImageOutput.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/js/src/shiny-react/ImageOutput.tsx b/js/src/shiny-react/ImageOutput.tsx index a791e350..d548d7c4 100644 --- a/js/src/shiny-react/ImageOutput.tsx +++ b/js/src/shiny-react/ImageOutput.tsx @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useShinyInput, useShinyOutput } from "./use-shiny"; import { createDebouncedFn } from "./utils"; +import { + applyNamespace, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; export type ImageData = { src: string; @@ -135,6 +139,7 @@ export function ImageOutput({ height, debounceMs = 400, onRecalculating, + namespace: explicitNamespace, }: { id: string; className?: string; @@ -142,22 +147,28 @@ export function ImageOutput({ height?: string; debounceMs?: number; onRecalculating?: (isRecalculating: boolean) => void; + namespace?: string; }) { + // Apply namespace from context or explicit option + const contextNamespace = useShinyModuleNamespace(); + const namespace = explicitNamespace ?? contextNamespace; + const namespacedId = applyNamespace(id, namespace); + const [imgWidth, setImgWidth] = useShinyInput( - ".clientdata_output_" + id + "_width", + `.clientdata_output_${namespacedId}_width`, null, ); const [imgHeight, setImgHeight] = useShinyInput( - ".clientdata_output_" + id + "_height", + `.clientdata_output_${namespacedId}_height`, null, ); // Track if the image is hidden const [imgHidden] = useShinyInput( - ".clientdata_output_" + id + "_hidden", + `.clientdata_output_${namespacedId}_hidden`, false, ); - const [imgData, imgRecalculating] = useShinyOutput(id, undefined); + const [imgData, imgRecalculating] = useShinyOutput(namespacedId, undefined); // Create a reference to the img element to access its properties const imgRef = useRef(null); From abe4f4356569aedef93dd5b18899d152dbb3876b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:25:27 -0400 Subject: [PATCH 07/15] feat: add ShinyReactComponentElement base class and update exports Add ShinyReactComponentElement, a base HTMLElement subclass for creating custom elements that render React components with automatic Shiny integration. Features include namespace support via ShinyModuleProvider, slot preservation for blended content, data-* config parsing, and proper Shiny binding lifecycle management. Update shiny-react index.ts to export ShinyReactComponentElement, ShinyModuleProvider, and useShinyModuleNamespace. Co-Authored-By: Claude Opus 4.6 --- .../ShinyReactComponentElement.tsx | 218 ++++++++++++++++++ .../ShinyReactComponentElement.test.tsx | 118 ++++++++++ js/src/shiny-react/index.ts | 5 + 3 files changed, 341 insertions(+) create mode 100644 js/src/shiny-react/ShinyReactComponentElement.tsx create mode 100644 js/src/shiny-react/__tests__/ShinyReactComponentElement.test.tsx diff --git a/js/src/shiny-react/ShinyReactComponentElement.tsx b/js/src/shiny-react/ShinyReactComponentElement.tsx new file mode 100644 index 00000000..c3adaca0 --- /dev/null +++ b/js/src/shiny-react/ShinyReactComponentElement.tsx @@ -0,0 +1,218 @@ +import { createRoot, type Root } from "react-dom/client"; +import { ShinyModuleProvider } from "./ShinyModuleContext"; + +/** + * Base class for creating custom elements that render React components + * with automatic Shiny integration. + * + * Features: + * - Automatic namespace support via ShinyModuleProvider (uses element's id) + * - Slot preservation for blended React + Shiny content + * - Config parsing from data-* attributes (with JSON auto-parsing) + * - Proper Shiny binding lifecycle (bindAll/unbindAll) + * - Default slot capture: When no [data-slot] elements are found, all children + * are captured under the reserved slot name "__children__" + * + * @example Simple widget (no slots): + * ```typescript + * class MyCounterElement extends ShinyReactComponentElement { + * static component = CounterWidget; + * } + * customElements.define('my-counter', MyCounterElement); + * ``` + * + * @example Blended component (with slots): + * ```typescript + * class MySidebarElement extends ShinyReactComponentElement { + * static component = SidebarLayout; + * + * protected render() { + * return ; + * } + * } + * customElements.define('my-sidebar', MySidebarElement); + * ``` + * + * @example Skip clearing innerHTML (rare): + * ```typescript + * class MyOverlayElement extends ShinyReactComponentElement { + * protected clearContent() {} // no-op + * } + * ``` + */ +export class ShinyReactComponentElement extends HTMLElement { + protected root: Root | null = null; + protected slotContents: Map = new Map(); + + /** + * The React component to render. Set this on your subclass. + * @example + * ```typescript + * class MyElement extends ShinyReactComponentElement { + * static component = MyReactComponent; + * } + * ``` + */ + static component: React.ComponentType> | null = null; + + /** + * Captures children with [data-slot] attribute, storing their contents + * keyed by slot name. Called automatically in connectedCallback. + * + * If no [data-slot] elements are found and the element has child nodes, + * all children are captured under the reserved slot name "__children__". + * This provides a default slot for components that don't use named slots. + * + * @param selector CSS selector for slot containers (default: '[data-slot]') + * @returns Map of slot names to their child nodes + */ + protected captureSlots( + selector: string = "[data-slot]", + ): Map { + const elements = this.querySelectorAll(selector); + if (elements.length === 0) { + // No named slots - capture all children as default slot + if (this.childNodes.length > 0) { + this.slotContents.set("__children__", Array.from(this.childNodes)); + } + } else { + // Named slots found - capture each one + elements.forEach((el) => { + const slotName = el.getAttribute("data-slot"); + if (slotName) { + this.slotContents.set(slotName, Array.from(el.childNodes)); + } + }); + } + return this.slotContents; + } + + /** + * Moves captured slot content into a container element and initializes + * Shiny bindings. Call this from your React component via onSlotMount callback. + * + * @param slotName The slot identifier (from data-slot attribute) + * @param container The DOM element to move content into + */ + protected async mountSlot( + slotName: string, + container: HTMLElement | null, + ): Promise { + const content = this.slotContents.get(slotName); + if (content && container) { + content.forEach((node) => container.appendChild(node)); + await (window as any).Shiny?.bindAll?.(container); + } + } + + /** + * Bound callback for mounting slots. Pass this to your React component + * to handle slot content mounting. + * + * @example + * ```typescript + * protected render() { + * return ; + * } + * ``` + */ + protected get onSlotMount(): ( + slotName: string, + el: HTMLElement | null, + ) => Promise { + return this.mountSlot.bind(this); + } + + /** + * Converts data-* attributes to a props object. + * Automatically attempts JSON parsing for rich values (numbers, booleans, + * arrays, objects). Falls back to string if parsing fails. + * + * @returns Config object with parsed data attributes + * + * @example + * ```html + * + * ``` + * Results in: { count: 5, enabled: true, items: [1,2,3], title: "Hello" } + */ + protected getConfig(): Record { + const config: Record = {}; + for (const [key, value] of Object.entries(this.dataset)) { + if (value === undefined) continue; + try { + config[key] = JSON.parse(value); + } catch { + config[key] = value; + } + } + return config; + } + + /** + * The namespace for Shiny module support, derived from the element's id. + * Returns undefined if no id is set. + */ + protected get namespace(): string | undefined { + return this.id || undefined; + } + + /** + * Renders the React component. Override this to customize rendering, + * pass additional props, or wrap in providers. + * + * @returns React node to render + */ + protected render(): React.ReactNode { + const Component = (this.constructor as typeof ShinyReactComponentElement) + .component; + if (!Component) { + console.error(`${this.constructor.name}: No static component defined`); + return null; + } + return ; + } + + /** + * Wraps content in ShinyModuleProvider if namespace exists. + */ + private wrapWithProvider(content: React.ReactNode): React.ReactNode { + if (this.namespace) { + return ( + + {content} + + ); + } + return content; + } + + /** + * Clears the element's innerHTML before React renders. + * Override with a no-op if you need to preserve existing content. + */ + protected clearContent(): void { + this.innerHTML = ""; + } + + /** + * Called when the element is added to the DOM. + * Captures slots, clears content, and renders the React component. + */ + connectedCallback(): void { + this.captureSlots(); + this.clearContent(); + this.root = createRoot(this); + this.root.render(this.wrapWithProvider(this.render())); + } + + /** + * Called when the element is removed from the DOM. + * Unbinds Shiny and unmounts the React root. + */ + disconnectedCallback(): void { + (window as any).Shiny?.unbindAll?.(this); + this.root?.unmount(); + this.root = null; + } +} diff --git a/js/src/shiny-react/__tests__/ShinyReactComponentElement.test.tsx b/js/src/shiny-react/__tests__/ShinyReactComponentElement.test.tsx new file mode 100644 index 00000000..5dd8c5b4 --- /dev/null +++ b/js/src/shiny-react/__tests__/ShinyReactComponentElement.test.tsx @@ -0,0 +1,118 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { ShinyReactComponentElement } from "../ShinyReactComponentElement"; + +// Register the custom element so jsdom allows `new ShinyReactComponentElement()` +customElements.define("test-shiny-react-component", 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]"); + + 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); + }); + }); +}); diff --git a/js/src/shiny-react/index.ts b/js/src/shiny-react/index.ts index 7705f572..5c6f3286 100644 --- a/js/src/shiny-react/index.ts +++ b/js/src/shiny-react/index.ts @@ -4,12 +4,17 @@ import { type ShinyMessageRegistry } from "./message-registry"; import { type ShinyReactRegistry } from "./react-registry"; export { ImageOutput } from "./ImageOutput"; +export { ShinyReactComponentElement } from "./ShinyReactComponentElement"; export { useShinyInitialized, useShinyInput, useShinyMessageHandler, useShinyOutput, } from "./use-shiny"; +export { + ShinyModuleProvider, + useShinyModuleNamespace, +} from "./ShinyModuleContext"; export type ShinyClassExtended = ShinyClass & { reactRegistry: ShinyReactRegistry; From 8ad81464d3a0c290aea96f32a52d511dc1a15174 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:26:58 -0400 Subject: [PATCH 08/15] feat: expose ShinyModuleProvider and ShinyReactComponentElement on window.shinyjson Co-Authored-By: Claude Opus 4.6 --- js/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/src/index.ts b/js/src/index.ts index d01e0130..7f5edcfe 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -19,6 +19,8 @@ import { useShinyMessageHandler, useShinyInitialized, ImageOutput, + ShinyModuleProvider, + ShinyReactComponentElement, } from "./shiny-react"; // Extend window with shinyjson's public global API @@ -34,6 +36,8 @@ declare global { useShinyMessageHandler: typeof useShinyMessageHandler; useShinyInitialized: typeof useShinyInitialized; ImageOutput: typeof ImageOutput; + ShinyModuleProvider: typeof ShinyModuleProvider; + ShinyReactComponentElement: typeof ShinyReactComponentElement; React: typeof React; ReactDOM: typeof ReactDOM; }; @@ -48,6 +52,8 @@ window.shinyjson = { useShinyMessageHandler, useShinyInitialized, ImageOutput, + ShinyModuleProvider, + ShinyReactComponentElement, React, ReactDOM, }; From 84180c0446ad921b7217ffbe6e892c411d9f713f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:27:49 -0400 Subject: [PATCH 09/15] fix: namespace post_message type using resolve_id for module support Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/shinyjson/_post_message.py | 5 ++++- pkg-py/tests/test_post_message.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/shinyjson/_post_message.py b/pkg-py/src/shinyjson/_post_message.py index e0860787..87ae84d6 100644 --- a/pkg-py/src/shinyjson/_post_message.py +++ b/pkg-py/src/shinyjson/_post_message.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +from shiny.module import resolve_id + if TYPE_CHECKING: from shiny.session import Session from shiny.types import Jsonifiable @@ -31,6 +33,7 @@ async def send_notification(): session, "notification", {"text": "Hello!", "level": "info"} ) """ + namespaced_type = resolve_id(type) await session.send_custom_message( - "shinyReactMessage", {"type": type, "data": data} + "shinyReactMessage", {"type": namespaced_type, "data": data} ) diff --git a/pkg-py/tests/test_post_message.py b/pkg-py/tests/test_post_message.py index f2d807b5..f228f712 100644 --- a/pkg-py/tests/test_post_message.py +++ b/pkg-py/tests/test_post_message.py @@ -1,3 +1,4 @@ +import unittest.mock from unittest.mock import AsyncMock import pytest @@ -37,3 +38,20 @@ async def test_sends_list_data(self): "shinyReactMessage", {"type": "update", "data": [1, 2, 3]}, ) + + @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"}}, + ) From 0b95fcd65ffe576237ae8c6f53a06e78a33b2fc4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 18 Mar 2026 15:28:07 -0400 Subject: [PATCH 10/15] 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. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + .../1-hello-world/.gitignore | 3 + .../1-hello-world/README.md | 63 ++ .../1-hello-world/package.json | 42 ++ .../1-hello-world/py/app.py | 16 + .../1-hello-world/py/shinyreact.py | 109 +++ .../1-hello-world/r/app.R | 11 + .../1-hello-world/r/shinyreact.R | 87 +++ .../srcts/HelloWorldComponent.tsx | 34 + .../1-hello-world/srcts/main.tsx | 11 + .../1-hello-world/srcts/styles.css | 184 +++++ .../1-hello-world/tsconfig.json | 19 + .../shiny-react-upstream/2-inputs/.gitignore | 3 + .../shiny-react-upstream/2-inputs/README.md | 93 +++ .../2-inputs/package.json | 42 ++ .../shiny-react-upstream/2-inputs/py/app.py | 66 ++ .../2-inputs/py/shinyreact.py | 109 +++ .../shiny-react-upstream/2-inputs/r/app.R | 58 ++ .../2-inputs/r/shinyreact.R | 84 +++ .../2-inputs/srcts/App.tsx | 32 + .../2-inputs/srcts/BatchFormCard.tsx | 126 ++++ .../2-inputs/srcts/ButtonInputCard.tsx | 53 ++ .../2-inputs/srcts/Card.tsx | 17 + .../2-inputs/srcts/CheckboxInputCard.tsx | 39 ++ .../2-inputs/srcts/DateInputCard.tsx | 38 ++ .../2-inputs/srcts/FileInputCard.tsx | 107 +++ .../2-inputs/srcts/InputOutputCard.tsx | 35 + .../2-inputs/srcts/NumberInputCard.tsx | 31 + .../2-inputs/srcts/RadioInputCard.tsx | 60 ++ .../2-inputs/srcts/SelectInputCard.tsx | 36 + .../2-inputs/srcts/SliderInputCard.tsx | 41 ++ .../2-inputs/srcts/TextInputCard.tsx | 29 + .../2-inputs/srcts/main.tsx | 16 + .../2-inputs/srcts/styles.css | 641 ++++++++++++++++++ .../2-inputs/tsconfig.json | 19 + .../shiny-react-upstream/3-outputs/.gitignore | 3 + .../shiny-react-upstream/3-outputs/README.md | 117 ++++ .../3-outputs/package.json | 37 + .../shiny-react-upstream/3-outputs/py/app.py | 79 +++ .../3-outputs/py/mtcars.csv | 33 + .../3-outputs/py/shinyreact.py | 109 +++ .../shiny-react-upstream/3-outputs/r/app.R | 56 ++ .../3-outputs/r/mtcars.csv | 33 + .../3-outputs/r/shinyreact.R | 84 +++ .../3-outputs/srcts/App.tsx | 20 + .../3-outputs/srcts/Card.tsx | 17 + .../3-outputs/srcts/DataTableCard.tsx | 58 ++ .../3-outputs/srcts/InputOutputCard.tsx | 28 + .../3-outputs/srcts/PlotCard.tsx | 15 + .../3-outputs/srcts/SliderCard.tsx | 26 + .../3-outputs/srcts/StatisticsCard.tsx | 76 +++ .../3-outputs/srcts/TableStatsCard.tsx | 141 ++++ .../3-outputs/srcts/main.tsx | 11 + .../3-outputs/srcts/styles.css | 390 +++++++++++ .../3-outputs/tsconfig.json | 19 + .../4-messages/.gitignore | 3 + .../shiny-react-upstream/4-messages/README.md | 114 ++++ .../4-messages/package.json | 38 ++ .../shiny-react-upstream/4-messages/py/app.py | 30 + .../4-messages/py/shinyreact.py | 109 +++ .../shiny-react-upstream/4-messages/r/app.R | 34 + .../4-messages/r/shinyreact.R | 84 +++ .../4-messages/srcts/App.tsx | 51 ++ .../4-messages/srcts/main.tsx | 11 + .../4-messages/srcts/styles.css | 249 +++++++ .../4-messages/tsconfig.json | 19 + .../shiny-react-upstream/5-shadcn/.gitignore | 3 + .../shiny-react-upstream/5-shadcn/README.md | 215 ++++++ .../shiny-react-upstream/5-shadcn/build.ts | 115 ++++ .../5-shadcn/components.json | 21 + .../5-shadcn/package.json | 44 ++ .../shiny-react-upstream/5-shadcn/py/app.py | 74 ++ .../5-shadcn/py/shinyreact.py | 109 +++ .../shiny-react-upstream/5-shadcn/r/app.R | 60 ++ .../5-shadcn/r/shinyreact.R | 84 +++ .../5-shadcn/srcts/components/App.tsx | 34 + .../srcts/components/ButtonEventCard.tsx | 43 ++ .../5-shadcn/srcts/components/PlotCard.tsx | 20 + .../srcts/components/TextInputCard.tsx | 59 ++ .../5-shadcn/srcts/components/ui/badge.tsx | 36 + .../5-shadcn/srcts/components/ui/button.tsx | 56 ++ .../5-shadcn/srcts/components/ui/card.tsx | 86 +++ .../5-shadcn/srcts/components/ui/input.tsx | 25 + .../srcts/components/ui/separator.tsx | 29 + .../5-shadcn/srcts/css.d.ts | 5 + .../5-shadcn/srcts/globals.css | 122 ++++ .../5-shadcn/srcts/main.tsx | 11 + .../5-shadcn/tsconfig.json | 20 + .../6-dashboard/.gitignore | 3 + .../6-dashboard/README.md | 222 ++++++ .../shiny-react-upstream/6-dashboard/build.ts | 115 ++++ .../6-dashboard/components.json | 21 + .../6-dashboard/package.json | 54 ++ .../6-dashboard/py/app.py | 85 +++ .../6-dashboard/py/data.py | 236 +++++++ .../6-dashboard/py/shinyreact.py | 109 +++ .../shiny-react-upstream/6-dashboard/r/app.R | 182 +++++ .../shiny-react-upstream/6-dashboard/r/data.R | 227 +++++++ .../6-dashboard/r/shinyreact.R | 84 +++ .../6-dashboard/srcts/components/Charts.tsx | 269 ++++++++ .../srcts/components/Dashboard.tsx | 34 + .../srcts/components/DataTable.tsx | 193 ++++++ .../srcts/components/FilterPanel.tsx | 155 +++++ .../srcts/components/MetricsCards.tsx | 111 +++ .../6-dashboard/srcts/components/Sidebar.tsx | 115 ++++ .../6-dashboard/srcts/components/ui/badge.tsx | 36 + .../srcts/components/ui/button.tsx | 56 ++ .../6-dashboard/srcts/components/ui/card.tsx | 86 +++ .../6-dashboard/srcts/components/ui/input.tsx | 25 + .../srcts/components/ui/separator.tsx | 29 + .../srcts/components/ui/skeleton.tsx | 15 + .../6-dashboard/srcts/components/ui/table.tsx | 114 ++++ .../6-dashboard/srcts/css.d.ts | 5 + .../6-dashboard/srcts/globals.css | 122 ++++ .../6-dashboard/srcts/main.tsx | 11 + .../6-dashboard/tsconfig.json | 20 + .../shiny-react-upstream/7-chat/.gitignore | 4 + .../shiny-react-upstream/7-chat/README.md | 247 +++++++ examples/shiny-react-upstream/7-chat/build.ts | 115 ++++ .../7-chat/components.json | 21 + .../shiny-react-upstream/7-chat/package.json | 47 ++ .../shiny-react-upstream/7-chat/py/app.py | 89 +++ .../7-chat/py/requirements.txt | 3 + .../7-chat/py/shinyreact.py | 109 +++ examples/shiny-react-upstream/7-chat/r/app.R | 115 ++++ .../7-chat/r/shinyreact.R | 84 +++ .../7-chat/srcts/components/ChatInterface.tsx | 307 +++++++++ .../7-chat/srcts/components/ImageInput.tsx | 172 +++++ .../7-chat/srcts/components/ImagePreview.tsx | 80 +++ .../7-chat/srcts/components/ThemeSwitcher.tsx | 130 ++++ .../7-chat/srcts/components/ui/avatar.tsx | 48 ++ .../7-chat/srcts/components/ui/button.tsx | 56 ++ .../7-chat/srcts/components/ui/card.tsx | 86 +++ .../7-chat/srcts/components/ui/input.tsx | 25 + .../srcts/components/ui/scroll-area.tsx | 46 ++ .../7-chat/srcts/contexts/ThemeContext.tsx | 116 ++++ .../7-chat/srcts/globals.css | 534 +++++++++++++++ .../7-chat/srcts/hooks/useDragAndDrop.ts | 66 ++ .../7-chat/srcts/hooks/useImageUpload.ts | 81 +++ .../7-chat/srcts/main.tsx | 16 + .../shiny-react-upstream/7-chat/tsconfig.json | 19 + .../shiny-react-upstream/8-modules/README.md | 266 ++++++++ .../8-modules/package.json | 55 ++ .../8-modules/py/app-standard.py | 220 ++++++ .../shiny-react-upstream/8-modules/py/app.py | 42 ++ .../8-modules/py/shinyreact.py | 109 +++ .../8-modules/r/app-standard.R | 236 +++++++ .../shiny-react-upstream/8-modules/r/app.R | 41 ++ .../8-modules/r/shinyreact.R | 87 +++ .../srcts-standard/CounterWidget.tsx | 38 ++ .../8-modules/srcts-standard/main.tsx | 11 + .../8-modules/srcts-standard/styles.css | 72 ++ .../8-modules/srcts/App.tsx | 55 ++ .../8-modules/srcts/CounterWidget.tsx | 47 ++ .../8-modules/srcts/main.tsx | 16 + .../8-modules/srcts/styles.css | 178 +++++ .../8-modules/tsconfig.json | 19 + .../shiny-react-upstream/9-blended/README.md | 169 +++++ .../9-blended/package.json | 39 ++ .../9-blended/python/app.py | 170 +++++ .../9-blended/python/shinyreact.py | 199 ++++++ .../shiny-react-upstream/9-blended/r/app.R | 153 +++++ .../9-blended/r/shinyreact.R | 174 +++++ .../9-blended/srcts/SidebarLayout.tsx | 116 ++++ .../9-blended/srcts/main.tsx | 30 + .../9-blended/srcts/styles.css | 169 +++++ .../9-blended/tsconfig.json | 19 + examples/shiny-react-upstream/README.md | 517 ++++++++++++++ pyproject.toml | 1 + 169 files changed, 14320 insertions(+) create mode 100644 examples/shiny-react-upstream/1-hello-world/.gitignore create mode 100644 examples/shiny-react-upstream/1-hello-world/README.md create mode 100644 examples/shiny-react-upstream/1-hello-world/package.json create mode 100644 examples/shiny-react-upstream/1-hello-world/py/app.py create mode 100644 examples/shiny-react-upstream/1-hello-world/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/1-hello-world/r/app.R create mode 100644 examples/shiny-react-upstream/1-hello-world/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/1-hello-world/srcts/HelloWorldComponent.tsx create mode 100644 examples/shiny-react-upstream/1-hello-world/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/1-hello-world/srcts/styles.css create mode 100644 examples/shiny-react-upstream/1-hello-world/tsconfig.json create mode 100644 examples/shiny-react-upstream/2-inputs/.gitignore create mode 100644 examples/shiny-react-upstream/2-inputs/README.md create mode 100644 examples/shiny-react-upstream/2-inputs/package.json create mode 100644 examples/shiny-react-upstream/2-inputs/py/app.py create mode 100644 examples/shiny-react-upstream/2-inputs/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/2-inputs/r/app.R create mode 100644 examples/shiny-react-upstream/2-inputs/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/App.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/BatchFormCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/ButtonInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/Card.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/CheckboxInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/DateInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/FileInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/InputOutputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/NumberInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/RadioInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/SelectInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/SliderInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/TextInputCard.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/2-inputs/srcts/styles.css create mode 100644 examples/shiny-react-upstream/2-inputs/tsconfig.json create mode 100644 examples/shiny-react-upstream/3-outputs/.gitignore create mode 100644 examples/shiny-react-upstream/3-outputs/README.md create mode 100644 examples/shiny-react-upstream/3-outputs/package.json create mode 100644 examples/shiny-react-upstream/3-outputs/py/app.py create mode 100644 examples/shiny-react-upstream/3-outputs/py/mtcars.csv create mode 100644 examples/shiny-react-upstream/3-outputs/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/3-outputs/r/app.R create mode 100644 examples/shiny-react-upstream/3-outputs/r/mtcars.csv create mode 100644 examples/shiny-react-upstream/3-outputs/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/App.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/Card.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/DataTableCard.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/InputOutputCard.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/PlotCard.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/SliderCard.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/StatisticsCard.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/TableStatsCard.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/3-outputs/srcts/styles.css create mode 100644 examples/shiny-react-upstream/3-outputs/tsconfig.json create mode 100644 examples/shiny-react-upstream/4-messages/.gitignore create mode 100644 examples/shiny-react-upstream/4-messages/README.md create mode 100644 examples/shiny-react-upstream/4-messages/package.json create mode 100644 examples/shiny-react-upstream/4-messages/py/app.py create mode 100644 examples/shiny-react-upstream/4-messages/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/4-messages/r/app.R create mode 100644 examples/shiny-react-upstream/4-messages/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/4-messages/srcts/App.tsx create mode 100644 examples/shiny-react-upstream/4-messages/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/4-messages/srcts/styles.css create mode 100644 examples/shiny-react-upstream/4-messages/tsconfig.json create mode 100644 examples/shiny-react-upstream/5-shadcn/.gitignore create mode 100644 examples/shiny-react-upstream/5-shadcn/README.md create mode 100644 examples/shiny-react-upstream/5-shadcn/build.ts create mode 100644 examples/shiny-react-upstream/5-shadcn/components.json create mode 100644 examples/shiny-react-upstream/5-shadcn/package.json create mode 100644 examples/shiny-react-upstream/5-shadcn/py/app.py create mode 100644 examples/shiny-react-upstream/5-shadcn/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/5-shadcn/r/app.R create mode 100644 examples/shiny-react-upstream/5-shadcn/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/App.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/ButtonEventCard.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/PlotCard.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/TextInputCard.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/ui/badge.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/ui/button.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/ui/card.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/ui/input.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/components/ui/separator.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/css.d.ts create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/globals.css create mode 100644 examples/shiny-react-upstream/5-shadcn/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/5-shadcn/tsconfig.json create mode 100644 examples/shiny-react-upstream/6-dashboard/.gitignore create mode 100644 examples/shiny-react-upstream/6-dashboard/README.md create mode 100644 examples/shiny-react-upstream/6-dashboard/build.ts create mode 100644 examples/shiny-react-upstream/6-dashboard/components.json create mode 100644 examples/shiny-react-upstream/6-dashboard/package.json create mode 100644 examples/shiny-react-upstream/6-dashboard/py/app.py create mode 100644 examples/shiny-react-upstream/6-dashboard/py/data.py create mode 100644 examples/shiny-react-upstream/6-dashboard/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/6-dashboard/r/app.R create mode 100644 examples/shiny-react-upstream/6-dashboard/r/data.R create mode 100644 examples/shiny-react-upstream/6-dashboard/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/Charts.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/Dashboard.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/DataTable.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/FilterPanel.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/MetricsCards.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/Sidebar.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/ui/badge.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/ui/button.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/ui/card.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/ui/input.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/ui/separator.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/ui/skeleton.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/components/ui/table.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/css.d.ts create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/globals.css create mode 100644 examples/shiny-react-upstream/6-dashboard/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/6-dashboard/tsconfig.json create mode 100644 examples/shiny-react-upstream/7-chat/.gitignore create mode 100644 examples/shiny-react-upstream/7-chat/README.md create mode 100644 examples/shiny-react-upstream/7-chat/build.ts create mode 100644 examples/shiny-react-upstream/7-chat/components.json create mode 100644 examples/shiny-react-upstream/7-chat/package.json create mode 100644 examples/shiny-react-upstream/7-chat/py/app.py create mode 100644 examples/shiny-react-upstream/7-chat/py/requirements.txt create mode 100644 examples/shiny-react-upstream/7-chat/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/7-chat/r/app.R create mode 100644 examples/shiny-react-upstream/7-chat/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ChatInterface.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ImageInput.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ImagePreview.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ThemeSwitcher.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ui/avatar.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ui/button.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ui/card.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ui/input.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/components/ui/scroll-area.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/contexts/ThemeContext.tsx create mode 100644 examples/shiny-react-upstream/7-chat/srcts/globals.css create mode 100644 examples/shiny-react-upstream/7-chat/srcts/hooks/useDragAndDrop.ts create mode 100644 examples/shiny-react-upstream/7-chat/srcts/hooks/useImageUpload.ts create mode 100644 examples/shiny-react-upstream/7-chat/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/7-chat/tsconfig.json create mode 100644 examples/shiny-react-upstream/8-modules/README.md create mode 100644 examples/shiny-react-upstream/8-modules/package.json create mode 100644 examples/shiny-react-upstream/8-modules/py/app-standard.py create mode 100644 examples/shiny-react-upstream/8-modules/py/app.py create mode 100644 examples/shiny-react-upstream/8-modules/py/shinyreact.py create mode 100644 examples/shiny-react-upstream/8-modules/r/app-standard.R create mode 100644 examples/shiny-react-upstream/8-modules/r/app.R create mode 100644 examples/shiny-react-upstream/8-modules/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/8-modules/srcts-standard/CounterWidget.tsx create mode 100644 examples/shiny-react-upstream/8-modules/srcts-standard/main.tsx create mode 100644 examples/shiny-react-upstream/8-modules/srcts-standard/styles.css create mode 100644 examples/shiny-react-upstream/8-modules/srcts/App.tsx create mode 100644 examples/shiny-react-upstream/8-modules/srcts/CounterWidget.tsx create mode 100644 examples/shiny-react-upstream/8-modules/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/8-modules/srcts/styles.css create mode 100644 examples/shiny-react-upstream/8-modules/tsconfig.json create mode 100644 examples/shiny-react-upstream/9-blended/README.md create mode 100644 examples/shiny-react-upstream/9-blended/package.json create mode 100644 examples/shiny-react-upstream/9-blended/python/app.py create mode 100644 examples/shiny-react-upstream/9-blended/python/shinyreact.py create mode 100644 examples/shiny-react-upstream/9-blended/r/app.R create mode 100644 examples/shiny-react-upstream/9-blended/r/shinyreact.R create mode 100644 examples/shiny-react-upstream/9-blended/srcts/SidebarLayout.tsx create mode 100644 examples/shiny-react-upstream/9-blended/srcts/main.tsx create mode 100644 examples/shiny-react-upstream/9-blended/srcts/styles.css create mode 100644 examples/shiny-react-upstream/9-blended/tsconfig.json create mode 100644 examples/shiny-react-upstream/README.md 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/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 = ( +
+
+ +