From eac72fe35cfe1839969d36180357de92dc9e98b0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 31 Aug 2026 11:29:08 -0400 Subject: [PATCH 1/5] feat: shinyreact_js= switch for npm-tier pages; convert 09-hmr (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page entry point unconditionally injected the IIFE bundle, so an app bundling `@posit/shinyreact` got two runtimes on the page. Add `shinyreact_js = "server" | "client"` (default `"server"`) to `page_react()`, `page_react_html()`, `set_react_page()`, and `ReactApp()`, mirrored in R. `"client"` omits shinyreact.js/.css only; the `#shinyreact-config` tag is always emitted, since the npm client hard-errors without it. Validation lives in one place per language, and the Express/App entry points check eagerly so a typo fails at startup. Convert `examples/09-hmr` to import `@posit/shinyreact` (`file:../../pkg-js` until the first publish): the dev/prod `shiny-bridge` alias and the React externalization are gone, and React is bundled in both modes, which is what Fast Refresh needs. Also warn from the npm entry when `window.shinyreact` is already present. The double load is harmless — the registries are page-scoped — but silent, and only the npm build can observe it, since script order guarantees the IIFE ran first. --- CLAUDE.md | 2 +- FEATURES.md | 43 +++++++++++---- decisions/2026-08-17-js-distribution.md | 23 +++++--- examples/09-hmr/FEATURES.md | 36 +++++++------ examples/09-hmr/README.md | 24 +++++++-- examples/09-hmr/app.py | 5 +- examples/09-hmr/package.json | 3 ++ examples/09-hmr/src/App.tsx | 2 +- examples/09-hmr/src/shiny-bridge.dev.ts | 12 ----- examples/09-hmr/src/shiny-bridge.prod.ts | 9 ---- examples/09-hmr/vite.config.js | 24 +++------ examples/README.md | 2 +- pkg-js/src/__tests__/entry-parity.test.ts | 44 ++++++++++++--- pkg-js/src/npm.ts | 25 +++++++++ .../__tests__/message-registry.test.ts | 5 +- pkg-js/src/shiny-react/message-registry.ts | 5 +- pkg-js/src/shiny-react/react-registry.ts | 5 +- pkg-py/src/shinyreact/_app.py | 16 +++++- pkg-py/src/shinyreact/_dep.py | 33 +++++++++++- pkg-py/src/shinyreact/_page.py | 54 +++++++++++++++---- .../playwright/test_module_dependency.py | 7 +-- pkg-py/tests/test_app.py | 17 ++++++ pkg-py/tests/test_page.py | 46 +++++++++++++--- pkg-py/tests/test_set_react_page.py | 21 ++++++++ pkg-r/R/dep.R | 27 +++++++++- pkg-r/R/page.R | 28 ++++++++-- pkg-r/man/page_react.Rd | 12 ++++- pkg-r/man/page_react_html.Rd | 10 +++- pkg-r/tests/testthat/test-page.R | 35 ++++++++++++ 29 files changed, 454 insertions(+), 121 deletions(-) delete mode 100644 examples/09-hmr/src/shiny-bridge.dev.ts delete mode 100644 examples/09-hmr/src/shiny-bridge.prod.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0cfc4732..e0d10693 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ The JS output (`pkg-js/dist/shinyreact.js`) is a self-contained IIFE that bundle **Default: module-level state, not globals.** A module singleton is testable, typed, and cannot be clobbered by another script on the page. Reach for it first. -Writing to `window` (including `window.Shiny.*`) is justified in exactly one situation: **state that must be shared per *page*, not per *bundle copy*.** Two copies of this library can be on one page today — the server injects the IIFE bundle even for an npm-tier app, until the opt-out in #217 lands — and each copy has its own module singletons. Anything that must be single per page has to travel through something both copies can see. +Writing to `window` (including `window.Shiny.*`) is justified in exactly one situation: **state that must be shared per *page*, not per *bundle copy*.** Two copies of this library can be on one page today — the page entry points serve `shinyreact.js` unless an npm-tier app passes `shinyreact_js="client"` (#217) — and each copy has its own module singletons. Anything that must be single per page has to travel through something both copies can see. The two sanctioned cases, both mediated by a single accessor: diff --git a/FEATURES.md b/FEATURES.md index 55b7f1c8..140386b6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -472,9 +472,10 @@ registries are exposed on `window.Shiny.reactRegistry`; the message registry on - it attaches the module singleton to `window.Shiny.messageRegistry` on first use (`??=`), so the first copy of the library to run owns the page and later copies adopt it - - two copies can coexist today (the server injects the IIFE even for npm-tier - apps until #217), and Shiny has one dispatcher slot per message type — two - registries would leave one copy's handlers dead + - two copies can still coexist (an npm-tier page that leaves + `shinyreact_js` at its default of `"server"` gets it as well), and Shiny has + one dispatcher slot per message type — two registries would leave one + copy's handlers dead - without `window.Shiny` it returns the module singleton and attaches nothing, since the client legitimately runs before Shiny loads - hooks call the accessor rather than reading `window.Shiny.messageRegistry`, @@ -536,9 +537,9 @@ registries are exposed on `window.Shiny.reactRegistry`; the message registry on `shinyreact-deps` handler and no ping, so the server never installed discovery for the session at all - pinned by `entry-parity.test.ts`, which imports each entry and asserts its - side effects — including the two *deliberate* tier differences (only the + side effects — including the three *deliberate* tier differences (only the IIFE installs `window.shinyreact`; only the npm build treats a missing - config tag as fatal) + config tag as fatal; only the npm build warns about a double load) - installing twice is a no-op, so calling it from both entries is safe ## `[js]` Client components @@ -608,6 +609,28 @@ Shared across all of them: the server emits no UI components. Each attaches the shinyreact bundle dependency and the `#shinyreact-config` tag — except `page_bare()`, which attaches neither. +`shinyreact_js=` is shared by `page_react()`, `page_react_html()`, `[py]` +`set_react_page()`, and `[py]` `ReactApp()` — who supplies `shinyreact.js` and +`shinyreact.css` to the page: + +- it defaults to `"server"` — the package serves both as an `HTMLDependency` +- `"client"` omits **both files**; the `#shinyreact-config` tag is still + emitted, because the npm-tier client hard-errors without it +- it is for the npm tier: a client importing `@posit/shinyreact` bundles its own + copy, so serving them too puts two copies of React and the hooks on the page +- any other value raises, naming the bad value and both valid ones + - `[py]` `ValueError`; `[r]` `cli_abort` + - `[py]` `set_react_page()` and `ReactApp()` validate eagerly at call time, + not at first page render, so a typo fails at app startup +- the server never validates the *choice* — it cannot: whether the client + bundles a copy is a property of the built `ui.js`, known only once it + executes, after the page's script tags are already committed + - wrong in one direction (`"server"`, client bundles too) → two copies; the + app works, and `[js]` the npm entry `console.warn`s naming `shinyreact_js` + - wrong in the other (`"client"`, client bundles nothing) → + `window.shinyreact` is `undefined` and the app's first hook call throws; no + shinyreact code is on the page to say anything about it + ### `page_bare(*args, title=None, lang="en", **kwargs)` - the escape hatch: Shiny's own dependencies, nothing of shinyreact's @@ -626,7 +649,7 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except - `[r]` needs nothing extra — `...` already forwards named arguments to `bootstrapPage()` -### `page_react(*args, src_dir=None, js_file="ui.js", css_file="ui.css", title=None, lang="en", **kwargs)` +### `page_react(*args, src_dir=None, js_file="ui.js", css_file="ui.css", title=None, lang="en", shinyreact_js="server", **kwargs)` - the zero-config page: no HTML file exists or is needed - it emits **no body HTML at all** — the client appends its own mount @@ -667,7 +690,7 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except - `[py]` needs no fallback: the path resolves against the calling module, so it is absolute whether or not it exists -### `page_react_html(path="www/index.html", extra_deps=None)` +### `page_react_html(path="www/index.html", extra_deps=None, shinyreact_js="server")` - for apps that own a complete HTML document (what a Vite build emits) - the document must contain `"` from npm. In the meantime, build the +package first: + +```bash +cd ../../pkg-js && npm install && npm run build +``` + ## How it works Shiny serves a `set_react_page()`-generated page (which loads `www/ui.js` as a module) and the @@ -16,10 +36,6 @@ Component code lives in `src/App.tsx` (the Fast Refresh boundary). The entry `src/ui.tsx` only mounts it — keep `createRoot()` there, never in a file that also defines components, or Fast Refresh falls back to a full reload. -In dev the example bundles its own dev React + the `shiny-react` hooks (Fast -Refresh needs a development React build). In the production build those are -externalized to the shared `window.shinyreact`. - ## Develop (two terminals) ```bash diff --git a/examples/09-hmr/app.py b/examples/09-hmr/app.py index d8ac78bf..ef28a707 100644 --- a/examples/09-hmr/app.py +++ b/examples/09-hmr/app.py @@ -1,7 +1,10 @@ from shiny.express import input from shinyreact import reactive_output, set_react_page -set_react_page() +# npm tier: the client imports `@posit/shinyreact` and bundles shinyreact.js +# itself, so the server must not serve it too -- two copies on one page. The +# `#shinyreact-config` tag is still emitted, and the npm client requires it. +set_react_page(shinyreact_js="client") # `input.count()` is pushed from App.tsx via useShinyInput("count", ...); the diff --git a/examples/09-hmr/package.json b/examples/09-hmr/package.json index c86d67b2..98f115ad 100644 --- a/examples/09-hmr/package.json +++ b/examples/09-hmr/package.json @@ -7,6 +7,9 @@ "dev": "vite", "test": "node --test" }, + "dependencies": { + "@posit/shinyreact": "file:../../pkg-js" + }, "devDependencies": { "@vitejs/plugin-react": "^4.3.0", "react": "^19.2.3", diff --git a/examples/09-hmr/src/App.tsx b/examples/09-hmr/src/App.tsx index 1fdf97b7..7083d91b 100644 --- a/examples/09-hmr/src/App.tsx +++ b/examples/09-hmr/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useShinyInitialized, useShinyInput, useShinyOutputValue } from "shiny-bridge"; +import { useShinyInitialized, useShinyInput, useShinyOutputValue } from "@posit/shinyreact"; // Exported component = a Fast Refresh boundary. Editing this file (e.g. the // heading text below) hot-swaps the component WITHOUT losing `count`. diff --git a/examples/09-hmr/src/shiny-bridge.dev.ts b/examples/09-hmr/src/shiny-bridge.dev.ts deleted file mode 100644 index 8b412dac..00000000 --- a/examples/09-hmr/src/shiny-bridge.dev.ts +++ /dev/null @@ -1,12 +0,0 @@ -// DEV path. The hooks come from the vendored shiny-react source and are bundled -// with this example's OWN dev React (Fast Refresh needs a dev React build; -// window.shinyreact.React is production). `resolve.dedupe` in vite.config keeps -// these and App.tsx on a single React copy. The relative path reaches the -// vendored source at the repo's pkg-js/src/shiny-react/ (served thanks to -// server.fs.allow in vite.config). Downstream apps would import a published -// @posit/shiny-react instead. -export { - useShinyInitialized, - useShinyInput, - useShinyOutputValue, -} from "../../../../pkg-js/src/shiny-react/index"; diff --git a/examples/09-hmr/src/shiny-bridge.prod.ts b/examples/09-hmr/src/shiny-bridge.prod.ts deleted file mode 100644 index 628db859..00000000 --- a/examples/09-hmr/src/shiny-bridge.prod.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// PROD path. The hooks come from the shinyreact bridge global, sharing the one -// React instance that owns them. This file is aliased in by vite.config only for -// `vite build`. -const sr = (window as any).shinyreact; - -export const useShinyInitialized = sr.useShinyInitialized; -export const useShinyInput = sr.useShinyInput; -export const useShinyOutputValue = sr.useShinyOutputValue; diff --git a/examples/09-hmr/vite.config.js b/examples/09-hmr/vite.config.js index c0a1dae0..28abe8cf 100644 --- a/examples/09-hmr/vite.config.js +++ b/examples/09-hmr/vite.config.js @@ -21,19 +21,15 @@ export default defineConfig(({ command }) => ({ shinyreactDevStub({ entry: ENTRY, outFile: "www/ui.js" }), ], resolve: { - // One React instance across App.tsx and the bundled shiny-react source. + // `@posit/shinyreact` is a `file:` dep, so it is symlinked and brings its + // own node_modules. Without dedupe, App.tsx and the hooks would each get a + // React copy and every hook call would throw. dedupe: ["react", "react-dom"], - alias: { - "shiny-bridge": path.resolve( - __dirname, - command === "serve" ? "src/shiny-bridge.dev.ts" : "src/shiny-bridge.prod.ts", - ), - }, }, server: { port: 5173, strictPort: true, // keep the stub's hard-coded :5173 honest - // Allow serving the vendored shiny-react source that lives outside this dir. + // `@posit/shinyreact` resolves through a symlink to the repo's pkg-js/. fs: { allow: [repoRoot] }, }, build: { @@ -45,15 +41,7 @@ export default defineConfig(({ command }) => ({ name: "HmrExample", fileName: () => "ui.js", }, - rollupOptions: { - external: ["react", "react-dom", "react-dom/client"], - output: { - globals: { - react: "window.shinyreact.React", - "react-dom": "window.shinyreact.ReactDOM", - "react-dom/client": "window.shinyreact.ReactDOM", - }, - }, - }, + // No externals: this app bundles its own React, in both modes. That is the + // point of the npm tier -- a development React with Fast Refresh in dev. }, })); diff --git a/examples/README.md b/examples/README.md index a75e63aa..c17b443b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,7 +29,7 @@ see [Example behavior trees](#example-behavior-trees) below. | [06-data-frame](06-data-frame/) | Embeds `@render.data_frame` via `ShinyOutput` and `set_react_page()` | | [07-plotly](07-plotly/) | Embeds `@render_plotly` via `ShinyOutput` and `set_react_page()`. Also ships an `app.R` using `plotly::renderPlotly()` over the same `www/` client — its binding JS is discovered from the render function and pushed automatically | | [08-input-handler](08-input-handler/) | `useShinyInput` with `type="shiny.datetime"` — client sends unix seconds; server `input.when()` is a `datetime.datetime` via Shiny's built-in handler | -| [09-hmr](09-hmr/) | React Fast Refresh in dev (Vite dev server alongside Shiny); the `app.py` and no-build `www/ui.js` paths reload too | +| [09-hmr](09-hmr/) | React Fast Refresh in dev (Vite dev server alongside Shiny). The npm tier: imports `@posit/shinyreact` and bundles its own React, with `set_react_page(shinyreact_js="client")` so the server doesn't also serve shinyreact.js | | [10-bookmarking](10-bookmarking/) | Bookmark restoration: URL query string (or server-stored state) hydrates `useShinyInput` initial values via the `#shinyreact-config` tag emitted by `page_react()` | ## Example behavior trees diff --git a/pkg-js/src/__tests__/entry-parity.test.ts b/pkg-js/src/__tests__/entry-parity.test.ts index e6f63dea..52756fa4 100644 --- a/pkg-js/src/__tests__/entry-parity.test.ts +++ b/pkg-js/src/__tests__/entry-parity.test.ts @@ -30,27 +30,34 @@ function fakeShiny() { beforeEach(() => { vi.resetModules(); (window as any).Shiny = fakeShiny(); + // `installGlobal()` leaves `window.shinyreact` behind, and resetModules() + // does not undo DOM writes. Without this, whether a test sees the global + // depends on which tests ran before it. + delete (window as any).shinyreact; }); afterEach(() => { delete (window as any).Shiny; + delete (window as any).shinyreact; }); describe("entry point parity", () => { it("the IIFE entry installs dependency discovery", async () => { await import("../index"); - expect( - (window as any).Shiny.addCustomMessageHandler, - ).toHaveBeenCalledWith("shinyreact-deps", expect.any(Function)); + expect((window as any).Shiny.addCustomMessageHandler).toHaveBeenCalledWith( + "shinyreact-deps", + expect.any(Function), + ); }); it("the npm entry installs dependency discovery too (#233)", async () => { await import("../npm"); - expect( - (window as any).Shiny.addCustomMessageHandler, - ).toHaveBeenCalledWith("shinyreact-deps", expect.any(Function)); + expect((window as any).Shiny.addCustomMessageHandler).toHaveBeenCalledWith( + "shinyreact-deps", + expect.any(Function), + ); }); it("both entries send the .shinyreact_init bootstrap ping", async () => { @@ -77,7 +84,6 @@ describe("entry point parity", () => { // Deliberate divergence, not drift: npm consumers import the hooks // directly, and the global exists so no-build pages can read them off // `window`. Pinned so flipping it is a decision. - delete (window as any).shinyreact; await import("../npm"); expect((window as any).shinyreact).toBeUndefined(); @@ -85,4 +91,28 @@ describe("entry point parity", () => { await import("../index"); expect((window as any).shinyreact).toBeDefined(); }); + + it("the npm entry warns when the IIFE bundle is on the page too", async () => { + // The double-load case: an npm-tier app whose page left shinyreact_js at + // its default of "server". Harmless but wasteful, and silent until now — + // the warning is the only thing that says to pass shinyreact_js="client". + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await import("../index"); // the IIFE, as the server would inject it + vi.resetModules(); // a *separate* copy of the library, as on a real page + await import("../npm"); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]?.[0]).toMatch(/shinyreact_js="client"/); + warn.mockRestore(); + }); + + it("the npm entry is silent when it is the only runtime", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await import("../npm"); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); }); diff --git a/pkg-js/src/npm.ts b/pkg-js/src/npm.ts index e6bf8596..0ad34bce 100644 --- a/pkg-js/src/npm.ts +++ b/pkg-js/src/npm.ts @@ -18,6 +18,31 @@ import "./shinyreact.css"; import { installDepDiscovery } from "./dep-discovery"; import { requireShinyReactConfigTag } from "./shiny-react/config"; +// Two copies on one page: this app bundles `@posit/shinyreact` AND the server +// served shinyreact.js, because the page entry point left `shinyreact_js` at its +// default of "server". It still works — the registries are page-scoped, so the +// two copies share one set of inputs, outputs, and message handlers — but the +// page downloads and parses a whole second React + hooks for nothing. +// +// A warning rather than a throw: nothing is broken, and taking down a working +// app over wasted bytes would be the wrong trade. It lives here, in the entry, +// because only the npm build can detect this — deferred classic scripts and +// module scripts execute in document order, and the page emits the bundle +// dependency before the app's, so by the time this runs the global is already +// installed if it is going to be. `installGlobal()` runs first and sees nothing. +if ( + typeof window !== "undefined" && + (window as { shinyreact?: unknown }).shinyreact +) { + console.warn( + "[shinyreact] shinyreact.js is loaded twice on this page: the server " + + "served it, and this app also imports @posit/shinyreact. The app works, " + + "but it is downloading a second copy of React and the hooks for " + + 'nothing. Pass shinyreact_js="client" to your page entry point — ' + + "page_react(), page_react_html(), set_react_page(), or ReactApp().", + ); +} + // An independently-installed client meeting a page without the // `#shinyreact-config` tag means the server predates the wire protocol — // fail loudly instead of degrading silently (see protocol/README.md §4). diff --git a/pkg-js/src/shiny-react/__tests__/message-registry.test.ts b/pkg-js/src/shiny-react/__tests__/message-registry.test.ts index 9287e024..379559ff 100644 --- a/pkg-js/src/shiny-react/__tests__/message-registry.test.ts +++ b/pkg-js/src/shiny-react/__tests__/message-registry.test.ts @@ -127,8 +127,9 @@ describe("getMessageRegistry", () => { it("adopts a registry another copy of the library already attached", async () => { // The page-scoped-not-module-scoped property this design exists for. Two - // copies of the bundle can coexist today (the server injects the IIFE even - // for npm-tier apps until #217), and each has its own module singleton. + // copies of the bundle can coexist today (the page entry points serve + // shinyreact.js unless an npm-tier app passes `shinyreact_js="client"`, + // #217), and each has its own module singleton. // Whoever attaches first owns the page; everyone else must adopt it, or // there would be two dispatchers competing for Shiny's single slot per // message type and one copy's handlers would go dead. diff --git a/pkg-js/src/shiny-react/message-registry.ts b/pkg-js/src/shiny-react/message-registry.ts index dc265b2c..7d64a69e 100644 --- a/pkg-js/src/shiny-react/message-registry.ts +++ b/pkg-js/src/shiny-react/message-registry.ts @@ -118,8 +118,9 @@ const messageRegistry = new ShinyMessageRegistry(); * * Deliberately page-scoped rather than module-scoped, and the one place that * attaches it to `window.Shiny`. Two copies of this library can be on a page - * today — the server injects the IIFE bundle even for an npm-tier app, until - * the opt-out in #217 lands — and each copy has its own module singleton. Two + * today — the page entry points serve shinyreact.js unless an npm-tier app + * passes `shinyreact_js="client"` (#217) — and each copy has its own module + * singleton. Two * registries would mean two `addCustomMessageHandler("shinyReactMessage")` * calls, and Shiny gives us one dispatcher slot per message type: whichever * behaviour it has (silently replacing the first, or throwing), one copy's diff --git a/pkg-js/src/shiny-react/react-registry.ts b/pkg-js/src/shiny-react/react-registry.ts index bc161ee0..67991c38 100644 --- a/pkg-js/src/shiny-react/react-registry.ts +++ b/pkg-js/src/shiny-react/react-registry.ts @@ -32,8 +32,9 @@ export function initializeReactRegistry(): void { * The registry pair for this *page*. * * Page-scoped for the reason spelled out in CLAUDE.md: two copies of this - * library can be on one page (the server injects the IIFE even for npm-tier - * apps until #217), and two registries would split one input id's producers + * library can be on one page (the page entry points serve shinyreact.js unless + * an npm-tier app passes `shinyreact_js="client"`, #217), and two registries + * would split one input id's producers * from its consumers. `??=` so the first copy to run owns the page and later * copies adopt it. * diff --git a/pkg-py/src/shinyreact/_app.py b/pkg-py/src/shinyreact/_app.py index ab1b0dc5..0556c95b 100644 --- a/pkg-py/src/shinyreact/_app.py +++ b/pkg-py/src/shinyreact/_app.py @@ -17,6 +17,8 @@ from shiny import ui as _shiny_ui from shiny.types import MISSING, MISSING_TYPE +from ._dep import ShinyreactJs, _serves_bundle + if TYPE_CHECKING: from htmltools import HTMLDependency @@ -107,6 +109,11 @@ class ReactApp(_ShinyApp): bookmark_store: ``"url"`` / ``"server"`` to enable bookmarking, as for :class:`shiny.App`. Requires a callable UI, which discovery provides; a static ``ui=page_react_html(...)`` raises. + shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css`` to + the discovered UI: ``"server"`` (default) or ``"client"`` for an + npm-tier app whose bundle imports ``@posit/shinyreact``. Ignored + when ``ui=`` is passed — build that UI with + ``shinyreact_js="client"`` yourself. **kwargs: Forwarded to :class:`shiny.App` (``debug=``, ``test_mode=``). Static assets @@ -152,8 +159,13 @@ def __init__( ui: Any = None, static_assets: StaticAssets | None | MISSING_TYPE = MISSING, bookmark_store: Literal["url", "server", "disable"] = "disable", + shinyreact_js: ShinyreactJs = "server", **kwargs: Any, ) -> None: + # Validate now rather than at first page render: a typo should fail at + # startup, next to the call that made it. + _serves_bundle(shinyreact_js) + # The directory holding the React bundle, to be mounted at "/". react_dir: Path | None = None @@ -175,8 +187,8 @@ def discovered_ui(request: Any) -> Any: # www/index.html during a dev session now switches modes without # a restart. `exists()` is one stat call per page render. if index_path.exists(): - return page_react_html(index_path) - return page_react(src_dir=src_dir) + return page_react_html(index_path, shinyreact_js=shinyreact_js) + return page_react(src_dir=src_dir, shinyreact_js=shinyreact_js) ui = discovered_ui diff --git a/pkg-py/src/shinyreact/_dep.py b/pkg-py/src/shinyreact/_dep.py index b0acf266..849606bf 100644 --- a/pkg-py/src/shinyreact/_dep.py +++ b/pkg-py/src/shinyreact/_dep.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Literal from htmltools import HTMLDependency, TagChild, TagList @@ -7,6 +8,10 @@ _WWW_DIR = Path(__file__).parent / "www" _SHINYREACT_JS_PATH = _WWW_DIR / "shinyreact.js" +# Who supplies shinyreact.js (and shinyreact.css) to the page. +ShinyreactJs = Literal["server", "client"] +_SHINYREACT_JS_VALUES = ("server", "client") + def _file_mtime_int(path: Path) -> int | None: """Return the file's mtime in whole seconds, or None if it doesn't exist.""" @@ -31,11 +36,35 @@ def _dep() -> HTMLDependency: ) -def _dep_page() -> TagChild: +def _serves_bundle(shinyreact_js: ShinyreactJs) -> bool: + """Validate ``shinyreact_js=`` and say whether the page attaches the bundle. + + The one place the value is checked, so every entry point rejects a typo the + same way. A bad value is a startup error rather than a page that silently + loads no hooks. + """ + if shinyreact_js not in _SHINYREACT_JS_VALUES: + expected = ", ".join(repr(v) for v in _SHINYREACT_JS_VALUES) + raise ValueError( + f"shinyreact_js={shinyreact_js!r} is not valid. Expected one of " + f'{expected}. Use "server" when the shinyreact package should serve ' + "shinyreact.js (the default, and what a no-build app needs), and " + '"client" when your own bundle imports @posit/shinyreact and ' + "therefore ships its own copy." + ) + return shinyreact_js == "server" + + +def _dep_page(shinyreact_js: ShinyreactJs = "server") -> TagChild: """Page-level shinyreact dependency: bundle + ``#shinyreact-config`` tag. Use from page entry points (``page_react_html``, ``set_react_page``'s page function) — the config tag carries the protocol version on every page and the bookmark restore payload when one is active. + + ``shinyreact_js="client"`` omits ``shinyreact.js`` / ``shinyreact.css`` for + npm-tier pages, whose client bundle ships its own copy. The config tag is + always emitted: the npm client hard-errors without it. """ - return TagList(_dep(), _config_script_tag()) + bundle = _dep() if _serves_bundle(shinyreact_js) else None + return TagList(bundle, _config_script_tag()) diff --git a/pkg-py/src/shinyreact/_page.py b/pkg-py/src/shinyreact/_page.py index 6a677d50..4749f6ee 100644 --- a/pkg-py/src/shinyreact/_page.py +++ b/pkg-py/src/shinyreact/_page.py @@ -12,7 +12,7 @@ from ._app import ReactHtmlDocument from ._bookmark import _config_script_tag -from ._dep import _dep, _dep_page, _file_mtime_int +from ._dep import ShinyreactJs, _dep, _dep_page, _file_mtime_int, _serves_bundle if TYPE_CHECKING: # Private, but it is the only name for HTMLDependency's stylesheet entry. @@ -79,6 +79,7 @@ def page_react( css_file: str | None = "ui.css", title: str | None = None, lang: str = "en", + shinyreact_js: ShinyreactJs = "server", **kwargs: Any, ) -> Tag: """Create a React page from conventional assets — no HTML file required. @@ -108,6 +109,18 @@ def page_react( title: Page title. Defaults to the app folder's name (``src_dir``'s parent when ``src_dir`` is a ``www/`` dir). lang: HTML ``lang`` attribute. + shinyreact_js: Who supplies ``shinyreact.js`` (and + ``shinyreact.css``) to the page. + + - ``"server"`` (default) — the shinyreact package serves them as an + :class:`~htmltools.HTMLDependency`. What a no-build app needs, + and what makes ``window.shinyreact`` exist. + - ``"client"`` — your own bundle imports ``@posit/shinyreact`` and + ships its own copy, so the server sends nothing. Serving them too + would put two copies of React and the hooks on one page. + + The ``#shinyreact-config`` tag is emitted either way; the npm-tier + client hard-errors without it. **kwargs: Forwarded to :func:`page_bare`, and on to :func:`shiny.ui.page_bootstrap`. """ @@ -115,7 +128,7 @@ def page_react( caller_dir = Path(caller_file).parent if caller_file else Path.cwd() base_dir, app_name = _resolve_react_dirs(src_dir, caller_dir) return page_bare( - _dep_page(), + _dep_page(shinyreact_js), page_react_dep( src_dir=base_dir, js_file=js_file, @@ -271,7 +284,9 @@ def _read_document_cached(path: Path) -> str: return text -def set_react_page(path: str | Path | None = None) -> None: +def set_react_page( + path: str | Path | None = None, *, shinyreact_js: ShinyreactJs = "server" +) -> None: """Set the page for this Express app to a React app (the ui.tsx pattern). With no arguments, serves ``www/index.html`` when it exists; otherwise @@ -341,7 +356,13 @@ def set_react_page(path: str | Path | None = None) -> None: or against ``Path.cwd()`` when there is no caller ``__file__``. When ``None`` (the default), uses ``www/index.html`` if it exists, else discovers ``www/ui.js`` / ``www/ui.css``. + shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css``: + ``"server"`` (default) or ``"client"`` for an npm-tier app whose + bundle imports ``@posit/shinyreact`` — see :func:`page_react`. """ + # Validate now rather than at first page render: a typo should fail at + # startup, next to the call that made it. + _serves_bundle(shinyreact_js) caller_file = sys._getframe(1).f_globals.get("__file__") # If the caller has no __file__ (REPL or dynamically exec'd code), # fall back to the current working directory. @@ -350,18 +371,21 @@ def set_react_page(path: str | Path | None = None) -> None: if path is None: index_path = caller_dir / "www" / "index.html" if not index_path.exists(): - page_opts(page_fn=_build_react_page_fn_discovered(caller_dir)) + page_opts( + page_fn=_build_react_page_fn_discovered(caller_dir, shinyreact_js) + ) return else: path = Path(path) index_path = path if path.is_absolute() else caller_dir / path - page_opts(page_fn=_build_react_page_fn(index_path)) + page_opts(page_fn=_build_react_page_fn(index_path, shinyreact_js)) def page_react_html( path: str | Path = "www/index.html", *, extra_deps: list[HTMLDependency] | None = None, + shinyreact_js: ShinyreactJs = "server", ) -> ReactHtmlDocument: """Serve a React ``index.html`` document (the ui.tsx pattern, Core API). @@ -405,6 +429,9 @@ def page_react_html( counterpart of :func:`page_react`'s positional ``*args``. They render *after* Shiny's and shinyreact's, so they can rely on ``window.shinyreact`` existing. + shinyreact_js: Who supplies ``shinyreact.js`` / ``shinyreact.css``: + ``"server"`` (default) or ``"client"`` for an npm-tier app whose + bundle imports ``@posit/shinyreact`` — see :func:`page_react`. """ path = Path(path) if path.is_absolute(): @@ -424,7 +451,11 @@ def page_react_html( return ReactHtmlDocument( _read_document_cached(index_path), src_dir=index_path.parent, - extra_deps=[_dep(), _config_script_tag(), *(extra_deps or [])], + extra_deps=[ + *([_dep()] if _serves_bundle(shinyreact_js) else []), + _config_script_tag(), + *(extra_deps or []), + ], ) @@ -491,7 +522,9 @@ def _react_page_opts(kwargs: dict[str, Any], *, mode: str) -> dict[str, Any]: return {k: v for k, v in kwargs.items() if v is not None} -def _build_react_page_fn_discovered(app_dir: Path) -> Callable[..., Tag]: +def _build_react_page_fn_discovered( + app_dir: Path, shinyreact_js: ShinyreactJs = "server" +) -> Callable[..., Tag]: """Express page function for the no-HTML-file mode. Serves a :func:`page_react` page from ``app_dir/www`` with the same @@ -504,13 +537,16 @@ def _react_page_fn(*args: Any, **kwargs: Any) -> Tag: return page_react( *_harvest_renderer_deps(args), src_dir=app_dir / "www", + shinyreact_js=shinyreact_js, **opts, ) return _react_page_fn -def _build_react_page_fn(index_path: Path) -> Callable[..., Tag]: +def _build_react_page_fn( + index_path: Path, shinyreact_js: ShinyreactJs = "server" +) -> Callable[..., Tag]: if not index_path.exists(): raise FileNotFoundError(f"HTML file not found: {index_path}") @@ -553,6 +589,6 @@ def _react_page_fn(*args: Any, **kwargs: Any) -> Tag: # Shiny de-duplicates dependencies by name+version when hoisting to # , so any overlap between the harvest passes is harmless. # page_opts types page_fn as -> Tag, but TagList works at runtime - return cast(Tag, TagList(_dep_page(), *deps, HTML(index_html))) + return cast(Tag, TagList(_dep_page(shinyreact_js), *deps, HTML(index_html))) return _react_page_fn diff --git a/pkg-py/tests/playwright/test_module_dependency.py b/pkg-py/tests/playwright/test_module_dependency.py index 09677fe7..c832332d 100644 --- a/pkg-py/tests/playwright/test_module_dependency.py +++ b/pkg-py/tests/playwright/test_module_dependency.py @@ -62,9 +62,10 @@ def test_late_dep_with_a_server_side_holder( """ # `lib/` prefix, not the bare name: the fixture's own explainer paragraph # mentions the dependency by name. - assert "lib/ipywidget-output-binding" not in page.request.get( - dynamic_plotly_app.url - ).text() + assert ( + "lib/ipywidget-output-binding" + not in page.request.get(dynamic_plotly_app.url).text() + ) page.goto(dynamic_plotly_app.url) diff --git a/pkg-py/tests/test_app.py b/pkg-py/tests/test_app.py index 707cdc37..57b013ab 100644 --- a/pkg-py/tests/test_app.py +++ b/pkg-py/tests/test_app.py @@ -121,6 +121,23 @@ def test_app_discovers_ui_js(tmp_path: Path, monkeypatch) -> None: assert f"{tmp_path.name}" in html +def test_app_shinyreact_js_client_omits_bundle(tmp_path: Path, monkeypatch) -> None: + """ReactApp(shinyreact_js="client") reaches both discovered modes (#217).""" + _write_react_assets(tmp_path) + app = _make_app_from_cwd(tmp_path, monkeypatch, shinyreact_js="client") + html = TestClient(app).get("/").text + assert "shinyreact.js" not in html + assert 'id="shinyreact-config"' in html + + # ...and again once www/index.html appears (mode is re-checked per request). + (tmp_path / "www" / "index.html").write_text( + f"T{DEPS}" + ) + html = TestClient(app).get("/").text + assert "shinyreact.js" not in html + assert 'id="shinyreact-config"' in html + + def test_app_discovered_ui_supports_bookmarking(tmp_path: Path, monkeypatch) -> None: # The discovered UI is a function of the request, so a bookmark query # string renders the restore payload with zero extra wiring. diff --git a/pkg-py/tests/test_page.py b/pkg-py/tests/test_page.py index bc2380ee..45eb081b 100644 --- a/pkg-py/tests/test_page.py +++ b/pkg-py/tests/test_page.py @@ -162,6 +162,44 @@ def test_page_react_attaches_bundle_app_dep_and_config(tmp_path): assert 'id="shinyreact-config"' in html +def test_page_react_shinyreact_js_client_omits_bundle(tmp_path): + """npm tier: no IIFE bundle, but the config tag is still required (#217). + + Mirrors R's "page_react(shinyreact_js = 'client') omits the bundle". + """ + from shinyreact import page_react + + app_dir = _make_react_app(tmp_path) + ui = page_react(src_dir=app_dir / "www", shinyreact_js="client") + names = [d.name for d in ui.get_dependencies()] + assert "shinyreact" not in names + assert "myapp" in names + html = _dep_tags_html(ui) + assert "shinyreact.js" not in html + assert 'id="shinyreact-config"' in html + + +def test_page_react_html_shinyreact_js_client_omits_bundle(tmp_path): + """Mirrors R's "page_react_html(shinyreact_js = 'client') omits the bundle".""" + index = tmp_path / "index.html" + index.write_text(_full_doc()) + html = _render_doc(page_react_html(index, shinyreact_js="client")) + assert "shinyreact.js" not in html + assert 'id="shinyreact-config"' in html + + +def test_shinyreact_js_rejects_an_unknown_value(tmp_path): + """A typo fails loudly, naming the value and the valid ones (#217). + + Mirrors R's "shinyreact_js rejects an unknown value". + """ + from shinyreact import page_react + + app_dir = _make_react_app(tmp_path) + with pytest.raises(ValueError, match=r"shinyreact_js='sever'.*'server', 'client'"): + page_react(src_dir=app_dir / "www", shinyreact_js="sever") # type: ignore[arg-type] + + def test_page_react_title_defaults_to_app_folder_name(tmp_path): from shinyreact import page_react @@ -320,9 +358,7 @@ def test_page_react_dep_version_is_the_js_mtime(tmp_path: Path) -> None: def _dep_html(ui) -> str: rendered = ui.tagify().render() - return "".join( - d.as_html_tags().get_html_string() for d in rendered["dependencies"] - ) + return "".join(d.as_html_tags().get_html_string() for d in rendered["dependencies"]) def test_page_bare_kwargs_reach_page_bootstrap() -> None: @@ -337,9 +373,7 @@ def test_page_bare_kwargs_reach_page_bootstrap() -> None: def test_page_react_kwargs_reach_page_bootstrap(tmp_path: Path) -> None: (tmp_path / "ui.js").write_text("// ui") - ui = shinyreact.page_react( - src_dir=tmp_path, theme="https://cdn.example/custom.css" - ) + ui = shinyreact.page_react(src_dir=tmp_path, theme="https://cdn.example/custom.css") assert 'href="https://cdn.example/custom.css"' in _dep_html(ui) diff --git a/pkg-py/tests/test_set_react_page.py b/pkg-py/tests/test_set_react_page.py index 2fa15f98..ce8f46fc 100644 --- a/pkg-py/tests/test_set_react_page.py +++ b/pkg-py/tests/test_set_react_page.py @@ -28,6 +28,27 @@ def test_build_page_fn_injects_shinyreact_dep(tmp_path: Path) -> None: assert "shinyreact" in dep_names +def test_build_page_fn_shinyreact_js_client_omits_bundle(tmp_path: Path) -> None: + """npm tier: no IIFE bundle, config tag still emitted (#217). + + Both `set_react_page()` modes: the HTML-file page_fn and the discovered one. + """ + index = tmp_path / "index.html" + index.write_text("
") + (tmp_path / "www").mkdir() + (tmp_path / "www" / "ui.js").write_text("// ui entry") + + for page_fn in ( + _build_react_page_fn(index, "client"), + _build_react_page_fn_discovered(tmp_path, "client"), + ): + rendered = _render(page_fn) + deps = rendered["dependencies"] + assert "shinyreact" not in [d.name for d in deps] + head = "".join(d.as_html_tags().get_html_string() for d in deps) + assert 'id="shinyreact-config"' in head + + def test_build_page_fn_discovers_renderer_deps(tmp_path: Path) -> None: """Deps from traditional Shiny renderers are auto-discovered.""" index = tmp_path / "index.html" diff --git a/pkg-r/R/dep.R b/pkg-r/R/dep.R index 50340349..299fbc39 100644 --- a/pkg-r/R/dep.R +++ b/pkg-r/R/dep.R @@ -31,13 +31,36 @@ shinyreact_dep <- function() { # client finds it by id either way, but the two servers disagreed about where a # documented `` tag goes, and `page_react_html()` (via `config_head_dep()`) # already put it in the head. -shinyreact_dep_page <- function() { +# +# `shinyreact_js = "client"` omits shinyreact.js / shinyreact.css for npm-tier +# pages, whose client bundle ships its own copy. The config tag is always +# emitted: the npm client hard-errors without it. +shinyreact_dep_page <- function(shinyreact_js = "server") { htmltools::tagList( - shinyreact_dep(), + if (serves_bundle(shinyreact_js)) shinyreact_dep(), htmltools::tags$head(config_script_tag()) ) } +# Internal: validate `shinyreact_js=` and say whether the page attaches the +# bundle. The one place the value is checked, so every entry point rejects a +# typo the same way. Mirrors Python's `_serves_bundle()`. +serves_bundle <- function(shinyreact_js) { + if ( + !identical(shinyreact_js, "server") && !identical(shinyreact_js, "client") + ) { + cli::cli_abort(c( + "{.arg shinyreact_js} must be {.val server} or {.val client}, + not {.val {shinyreact_js}}.", + "i" = "{.val server} (the default) serves {.file shinyreact.js} from the + shinyreact package -- what a no-build app needs.", + "i" = "{.val client} is for an app whose own bundle imports + {.pkg @posit/shinyreact} and therefore ships its own copy." + )) + } + identical(shinyreact_js, "server") +} + # Internal: the `#shinyreact-config` tag as an htmlDependency `head` entry, for # UIs where a plain tag has no place to land — htmlTemplate() documents render # attached dependencies at the dependency placeholder, and a dependency's `head` diff --git a/pkg-r/R/page.R b/pkg-r/R/page.R index c8d8ccf8..de6e455e 100644 --- a/pkg-r/R/page.R +++ b/pkg-r/R/page.R @@ -45,6 +45,14 @@ page_bare <- function(..., title = NULL, lang = "en") { #' that resolves to nothing usable (a missing `src_dir` is not an error — #' the bundle may not be built yet). #' @param lang HTML `lang` attribute. +#' @param shinyreact_js Who supplies `shinyreact.js` (and `shinyreact.css`) to +#' the page. `"server"` (the default) serves them from the shinyreact package +#' as an [htmltools::htmlDependency] — what a no-build app needs, and what +#' makes `window.shinyreact` exist. `"client"` is for an app whose own bundle +#' imports `@posit/shinyreact` and therefore ships its own copy; serving them +#' too would put two copies of React and the hooks on one page. The +#' `#shinyreact-config` tag is emitted either way; the npm-tier client +#' hard-errors without it. Mirrors Python's `page_react(shinyreact_js=)`. #' @return UI suitable for `shinyApp(ui = ...)`. #' @export page_react <- function( @@ -53,7 +61,8 @@ page_react <- function( js_file = "ui.js", css_file = "ui.css", title = NULL, - lang = "en" + lang = "en", + shinyreact_js = "server" ) { base_dir <- if (basename(src_dir) == "www") { @@ -70,7 +79,7 @@ page_react <- function( app_name <- "shinyreact-app" } page_bare( - shinyreact_dep_page(), + shinyreact_dep_page(shinyreact_js = shinyreact_js), page_react_dep( src_dir, js_file = js_file, @@ -140,9 +149,16 @@ page_react <- function( #' [page_react()]'s `...`. They render *after* Shiny's and shinyreact's, so #' they can rely on `window.shinyreact` existing. Mirrors Python's #' `page_react_html(extra_deps=)`. +#' @param shinyreact_js Who supplies `shinyreact.js` / `shinyreact.css`: +#' `"server"` (the default) or `"client"` for an npm-tier app whose bundle +#' imports `@posit/shinyreact` — see [page_react()]. #' @return UI suitable for `shinyApp(ui = ...)`. #' @export -page_react_html <- function(path = "www/index.html", extra_deps = NULL) { +page_react_html <- function( + path = "www/index.html", + extra_deps = NULL, + shinyreact_js = "server" +) { if (!file.exists(path)) { cli::cli_abort(c( "HTML file not found: {.path {path}}", @@ -167,7 +183,11 @@ page_react_html <- function(path = "www/index.html", extra_deps = NULL) { ui <- htmltools::htmlTemplate(text_ = html, document_ = TRUE) htmltools::attachDependencies( ui, - c(list(shinyreact_dep(), config_head_dep()), extra_deps), + c( + if (serves_bundle(shinyreact_js)) list(shinyreact_dep()), + list(config_head_dep()), + extra_deps + ), append = TRUE ) } diff --git a/pkg-r/man/page_react.Rd b/pkg-r/man/page_react.Rd index 10ef0d89..3f45e95d 100644 --- a/pkg-r/man/page_react.Rd +++ b/pkg-r/man/page_react.Rd @@ -10,7 +10,8 @@ page_react( js_file = "ui.js", css_file = "ui.css", title = NULL, - lang = "en" + lang = "en", + shinyreact_js = "server" ) } \arguments{ @@ -29,6 +30,15 @@ that resolves to nothing usable (a missing \code{src_dir} is not an error — the bundle may not be built yet).} \item{lang}{HTML \code{lang} attribute.} + +\item{shinyreact_js}{Who supplies \code{shinyreact.js} (and \code{shinyreact.css}) to +the page. \code{"server"} (the default) serves them from the shinyreact package +as an \link[htmltools:htmlDependency]{htmltools::htmlDependency} — what a no-build app needs, and what +makes \code{window.shinyreact} exist. \code{"client"} is for an app whose own bundle +imports \verb{@posit/shinyreact} and therefore ships its own copy; serving them +too would put two copies of React and the hooks on one page. The +\verb{#shinyreact-config} tag is emitted either way; the npm-tier client +hard-errors without it. Mirrors Python's \code{page_react(shinyreact_js=)}.} } \value{ UI suitable for \code{shinyApp(ui = ...)}. diff --git a/pkg-r/man/page_react_html.Rd b/pkg-r/man/page_react_html.Rd index eb269864..f4135303 100644 --- a/pkg-r/man/page_react_html.Rd +++ b/pkg-r/man/page_react_html.Rd @@ -4,7 +4,11 @@ \alias{page_react_html} \title{Serve a React \code{index.html} document (the \code{ui.tsx} pattern)} \usage{ -page_react_html(path = "www/index.html", extra_deps = NULL) +page_react_html( + path = "www/index.html", + extra_deps = NULL, + shinyreact_js = "server" +) } \arguments{ \item{path}{Path to the HTML document. Defaults to \code{"www/index.html"}, @@ -16,6 +20,10 @@ dependencies to, so this is the only way in — the counterpart of \code{\link[=page_react]{page_react()}}'s \code{...}. They render \emph{after} Shiny's and shinyreact's, so they can rely on \code{window.shinyreact} existing. Mirrors Python's \code{page_react_html(extra_deps=)}.} + +\item{shinyreact_js}{Who supplies \code{shinyreact.js} / \code{shinyreact.css}: +\code{"server"} (the default) or \code{"client"} for an npm-tier app whose bundle +imports \verb{@posit/shinyreact} — see \code{\link[=page_react]{page_react()}}.} } \value{ UI suitable for \code{shinyApp(ui = ...)}. diff --git a/pkg-r/tests/testthat/test-page.R b/pkg-r/tests/testthat/test-page.R index fc4b2199..209bca2c 100644 --- a/pkg-r/tests/testthat/test-page.R +++ b/pkg-r/tests/testthat/test-page.R @@ -156,6 +156,41 @@ test_that("page_react attaches bundle, app dep, and config", { expect_match(html, 'id="shinyreact-config"', fixed = TRUE) }) +test_that("page_react(shinyreact_js = 'client') omits the bundle", { + # npm tier: the client bundle ships its own copy, so shinyreact.js must not be + # served too -- but the config tag is still required (#217). Mirrors Python's + # test_page_react_shinyreact_js_client_omits_bundle. + dir <- local_react_app() + ui <- page_react(shinyreact_js = "client") + names <- vapply(htmltools::findDependencies(ui), function(d) d$name, "") + expect_false("shinyreact" %in% names) + expect_true(basename(dir) %in% names) + html <- dep_tags_html(ui) + expect_no_match(html, "shinyreact.js", fixed = TRUE) + expect_match(html, 'id="shinyreact-config"', fixed = TRUE) +}) + +test_that("page_react_html(shinyreact_js = 'client') omits the bundle", { + # Mirrors Python's test_page_react_html_shinyreact_js_client_omits_bundle. + tmp <- withr::local_tempfile(fileext = ".html") + write_full_doc(tmp) + html <- render_document(page_react_html(tmp, shinyreact_js = "client")) + expect_no_match(html, "shinyreact.js", fixed = TRUE) + expect_match(html, 'id="shinyreact-config"', fixed = TRUE) +}) + +test_that("shinyreact_js rejects an unknown value", { + # A typo fails loudly, naming the value and the valid ones (#217). Mirrors + # Python's test_shinyreact_js_rejects_an_unknown_value. + local_react_app() + expect_error(page_react(shinyreact_js = "sever"), "sever") + expect_error(page_react(shinyreact_js = "sever"), "client") + + tmp <- withr::local_tempfile(fileext = ".html") + write_full_doc(tmp) + expect_error(page_react_html(tmp, shinyreact_js = "sever"), "sever") +}) + test_that("page_react title defaults to the app folder name", { # Mirrors Python's test_page_react_title_defaults_to_app_folder_name. dir <- local_react_app() From bf0b05567b62a4a670c06284ce8fbd238ba2a2dc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 31 Aug 2026 12:05:29 -0400 Subject: [PATCH 2/5] refactor(r): empty ... before page_react_html()'s named arguments R's counterpart of Python's keyword-only `*`, so the same call reads the same in both languages. Anything reaching the dots is a positional argument the caller meant to name or a misspelled name; both now error instead of being silently dropped. Named arguments are reported by name (a misspelled `extra_dep=` names itself), unnamed ones by position. The dots are never evaluated, so a rejected argument cannot run its own expression on the way to being refused. --- FEATURES.md | 10 ++++++++ pkg-py/tests/test_page.py | 13 ++++++++++ pkg-r/R/page.R | 43 ++++++++++++++++++++++++++++++++ pkg-r/man/page_react_html.Rd | 6 +++++ pkg-r/tests/testthat/test-page.R | 20 +++++++++++++++ 5 files changed, 92 insertions(+) diff --git a/FEATURES.md b/FEATURES.md index 140386b6..92b266ee 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -737,6 +737,16 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except `...`) - they render **after** Shiny's and shinyreact's, so they can rely on `window.shinyreact` existing + - everything after `path` must be passed **by name** + - `[py]` a bare `*` in the signature; a second positional argument raises + `TypeError` naming positional arguments + - `[r]` an empty `...` before `extra_deps`, checked on entry; anything that + reaches it raises `` `...` must be empty `` + - named arguments are reported by name, so a misspelled `extra_dep=` names + itself; unnamed ones are reported by position (`..1`) + - the dots are never evaluated, so a rejected argument cannot run its own + expression on the way to being refused + - plural agreement: "Unexpected argument" vs "Unexpected arguments" - `[py]` it returns a `ReactHtmlDocument` - a `shiny.ui.PageDocument` subclass that also remembers the document's directory, so `ReactApp` can serve the assets the document references diff --git a/pkg-py/tests/test_page.py b/pkg-py/tests/test_page.py index 45eb081b..1eb96031 100644 --- a/pkg-py/tests/test_page.py +++ b/pkg-py/tests/test_page.py @@ -200,6 +200,19 @@ def test_shinyreact_js_rejects_an_unknown_value(tmp_path): page_react(src_dir=app_dir / "www", shinyreact_js="sever") # type: ignore[arg-type] +def test_page_react_html_arguments_after_path_are_keyword_only(tmp_path): + """Everything after ``path`` is keyword-only. + + Mirrors R's "page_react_html() rejects anything passed to ...", where a bare + ``...`` enforces the same thing. Asserted so the two signatures cannot drift. + """ + index = tmp_path / "index.html" + index.write_text(_full_doc()) + + with pytest.raises(TypeError, match="positional"): + page_react_html(index, []) # type: ignore[misc] + + def test_page_react_title_defaults_to_app_folder_name(tmp_path): from shinyreact import page_react diff --git a/pkg-r/R/page.R b/pkg-r/R/page.R index de6e455e..e43116c3 100644 --- a/pkg-r/R/page.R +++ b/pkg-r/R/page.R @@ -92,6 +92,43 @@ page_react <- function( ) } +# Internal: reject anything that lands in `...`. +# +# `page_react_html()`'s dots exist only to force the arguments after them to be +# named -- R's counterpart of Python's keyword-only `*`, so the same call reads +# the same in both languages. Anything that reaches `...` is either a positional +# argument the caller meant to name or a misspelled name; both deserve an error +# rather than being silently dropped. +# +# Reports names for named arguments (the misspelling case, where the name is the +# whole diagnosis) and `..1`-style positions for unnamed ones. Deliberately does +# not deparse the values: `...` is never evaluated, so a rejected argument +# cannot run someone's expensive -- or erroring -- expression on its way to +# being refused. +check_dots_empty <- function(...) { + n <- ...length() + if (n == 0L) { + return(invisible(NULL)) + } + nms <- names(match.call(expand.dots = FALSE)[["..."]]) + labels <- vapply( + seq_len(n), + function(i) { + if (!is.null(nms) && nzchar(nms[[i]])) nms[[i]] else paste0("..", i) + }, + character(1) + ) + cli::cli_abort( + c( + "{.arg ...} must be empty.", + "x" = "Unexpected argument{?s}: {.arg {labels}}.", + "i" = "Arguments after {.arg ...} must be named, e.g. + {.code extra_deps = } or {.code shinyreact_js = }." + ), + call = parent.frame() + ) +} + #' Serve a React `index.html` document (the `ui.tsx` pattern) #' #' Reads a complete HTML document — the kind a Vite build emits — and injects @@ -143,6 +180,10 @@ page_react <- function( #' #' @param path Path to the HTML document. Defaults to `"www/index.html"`, #' relative to the working directory. +#' @param ... These dots are for future extension and must be empty. They force +#' every argument after them to be named, matching Python, where the same +#' arguments are keyword-only. Passing anything here is an error naming what +#' it received. #' @param extra_deps A list of additional [htmltools::htmlDependency] objects to #' render at the placeholder. A complete document has no tag tree to attach #' dependencies to, so this is the only way in — the counterpart of @@ -156,9 +197,11 @@ page_react <- function( #' @export page_react_html <- function( path = "www/index.html", + ..., extra_deps = NULL, shinyreact_js = "server" ) { + check_dots_empty(...) if (!file.exists(path)) { cli::cli_abort(c( "HTML file not found: {.path {path}}", diff --git a/pkg-r/man/page_react_html.Rd b/pkg-r/man/page_react_html.Rd index f4135303..9e856564 100644 --- a/pkg-r/man/page_react_html.Rd +++ b/pkg-r/man/page_react_html.Rd @@ -6,6 +6,7 @@ \usage{ page_react_html( path = "www/index.html", + ..., extra_deps = NULL, shinyreact_js = "server" ) @@ -14,6 +15,11 @@ page_react_html( \item{path}{Path to the HTML document. Defaults to \code{"www/index.html"}, relative to the working directory.} +\item{...}{These dots are for future extension and must be empty. They force +every argument after them to be named, matching Python, where the same +arguments are keyword-only. Passing anything here is an error naming what +it received.} + \item{extra_deps}{A list of additional \link[htmltools:htmlDependency]{htmltools::htmlDependency} objects to render at the placeholder. A complete document has no tag tree to attach dependencies to, so this is the only way in — the counterpart of diff --git a/pkg-r/tests/testthat/test-page.R b/pkg-r/tests/testthat/test-page.R index 209bca2c..b76bef65 100644 --- a/pkg-r/tests/testthat/test-page.R +++ b/pkg-r/tests/testthat/test-page.R @@ -191,6 +191,26 @@ test_that("shinyreact_js rejects an unknown value", { expect_error(page_react_html(tmp, shinyreact_js = "sever"), "sever") }) +test_that("page_react_html() rejects anything passed to ...", { + # The dots exist only to force the later arguments to be named -- R's + # counterpart of Python's keyword-only `*`. Mirrors Python's + # test_page_react_html_arguments_after_path_are_keyword_only. + tmp <- withr::local_tempfile(fileext = ".html") + write_full_doc(tmp) + + # A positional argument is reported by position... + expect_error(page_react_html(tmp, list()), "must be empty") + expect_error(page_react_html(tmp, list()), "..1", fixed = TRUE) + # ...and a misspelled name by name, which is the whole diagnosis. + expect_error(page_react_html(tmp, extra_dep = list()), "extra_dep") + # Plural agreement, and both labels present. + expect_error(page_react_html(tmp, 1, foo = 2), "Unexpected arguments") + + # The named arguments still work. + expect_no_error(page_react_html(tmp, extra_deps = NULL)) + expect_no_error(page_react_html(tmp, shinyreact_js = "client")) +}) + test_that("page_react title defaults to the app folder name", { # Mirrors Python's test_page_react_title_defaults_to_app_folder_name. dir <- local_react_app() From aac956a7f1518bbcdbae3fc82bc7d05191e2b81f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 31 Aug 2026 12:12:44 -0400 Subject: [PATCH 3/5] refactor(r): use rlang::check_dots_empty() for page_react_html()'s dots Replaces the hand-rolled check from the previous commit. rlang's message is better -- it echoes each offending argument as `name = expr`, so a misspelled `extra_dep = list()` names itself -- and it carries the `rlib_error_dots_nonempty` class, which the test now asserts instead of matching wording that belongs to rlang. Adds rlang to Imports. --- FEATURES.md | 13 +++++---- pkg-r/DESCRIPTION | 1 + pkg-r/R/page.R | 46 +++----------------------------- pkg-r/man/page_react_html.Rd | 7 +++-- pkg-r/tests/testthat/test-page.R | 13 ++++----- 5 files changed, 21 insertions(+), 59 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 92b266ee..3d8fb307 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -740,13 +740,12 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except - everything after `path` must be passed **by name** - `[py]` a bare `*` in the signature; a second positional argument raises `TypeError` naming positional arguments - - `[r]` an empty `...` before `extra_deps`, checked on entry; anything that - reaches it raises `` `...` must be empty `` - - named arguments are reported by name, so a misspelled `extra_dep=` names - itself; unnamed ones are reported by position (`..1`) - - the dots are never evaluated, so a rejected argument cannot run its own - expression on the way to being refused - - plural agreement: "Unexpected argument" vs "Unexpected arguments" + - `[r]` an empty `...` before `extra_deps`, enforced by + `rlang::check_dots_empty()` on entry; anything that reaches it raises + `` `...` must be empty `` with class `rlib_error_dots_nonempty` + - each offending argument is echoed as `name = expr`, so a misspelled + `extra_dep = list()` names itself; unnamed ones show as `..1` + - the error is attributed to `page_react_html()`, not to the check - `[py]` it returns a `ReactHtmlDocument` - a `shiny.ui.PageDocument` subclass that also remembers the document's directory, so `ReactApp` can serve the assets the document references diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index dc06bde2..372744d8 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -18,6 +18,7 @@ Imports: cli, htmltools, jsonlite, + rlang, shiny (>= 1.13.0), utils Suggests: diff --git a/pkg-r/R/page.R b/pkg-r/R/page.R index e43116c3..581cbdb1 100644 --- a/pkg-r/R/page.R +++ b/pkg-r/R/page.R @@ -92,43 +92,6 @@ page_react <- function( ) } -# Internal: reject anything that lands in `...`. -# -# `page_react_html()`'s dots exist only to force the arguments after them to be -# named -- R's counterpart of Python's keyword-only `*`, so the same call reads -# the same in both languages. Anything that reaches `...` is either a positional -# argument the caller meant to name or a misspelled name; both deserve an error -# rather than being silently dropped. -# -# Reports names for named arguments (the misspelling case, where the name is the -# whole diagnosis) and `..1`-style positions for unnamed ones. Deliberately does -# not deparse the values: `...` is never evaluated, so a rejected argument -# cannot run someone's expensive -- or erroring -- expression on its way to -# being refused. -check_dots_empty <- function(...) { - n <- ...length() - if (n == 0L) { - return(invisible(NULL)) - } - nms <- names(match.call(expand.dots = FALSE)[["..."]]) - labels <- vapply( - seq_len(n), - function(i) { - if (!is.null(nms) && nzchar(nms[[i]])) nms[[i]] else paste0("..", i) - }, - character(1) - ) - cli::cli_abort( - c( - "{.arg ...} must be empty.", - "x" = "Unexpected argument{?s}: {.arg {labels}}.", - "i" = "Arguments after {.arg ...} must be named, e.g. - {.code extra_deps = } or {.code shinyreact_js = }." - ), - call = parent.frame() - ) -} - #' Serve a React `index.html` document (the `ui.tsx` pattern) #' #' Reads a complete HTML document — the kind a Vite build emits — and injects @@ -180,10 +143,9 @@ check_dots_empty <- function(...) { #' #' @param path Path to the HTML document. Defaults to `"www/index.html"`, #' relative to the working directory. -#' @param ... These dots are for future extension and must be empty. They force -#' every argument after them to be named, matching Python, where the same -#' arguments are keyword-only. Passing anything here is an error naming what -#' it received. +#' @param ... These dots are for future extensions and must be empty. They also +#' force every argument after them to be named, matching Python, where the +#' same arguments are keyword-only. #' @param extra_deps A list of additional [htmltools::htmlDependency] objects to #' render at the placeholder. A complete document has no tag tree to attach #' dependencies to, so this is the only way in — the counterpart of @@ -201,7 +163,7 @@ page_react_html <- function( extra_deps = NULL, shinyreact_js = "server" ) { - check_dots_empty(...) + rlang::check_dots_empty() if (!file.exists(path)) { cli::cli_abort(c( "HTML file not found: {.path {path}}", diff --git a/pkg-r/man/page_react_html.Rd b/pkg-r/man/page_react_html.Rd index 9e856564..bdc3c436 100644 --- a/pkg-r/man/page_react_html.Rd +++ b/pkg-r/man/page_react_html.Rd @@ -15,10 +15,9 @@ page_react_html( \item{path}{Path to the HTML document. Defaults to \code{"www/index.html"}, relative to the working directory.} -\item{...}{These dots are for future extension and must be empty. They force -every argument after them to be named, matching Python, where the same -arguments are keyword-only. Passing anything here is an error naming what -it received.} +\item{...}{These dots are for future extensions and must be empty. They also +force every argument after them to be named, matching Python, where the +same arguments are keyword-only.} \item{extra_deps}{A list of additional \link[htmltools:htmlDependency]{htmltools::htmlDependency} objects to render at the placeholder. A complete document has no tag tree to attach diff --git a/pkg-r/tests/testthat/test-page.R b/pkg-r/tests/testthat/test-page.R index b76bef65..b8e74361 100644 --- a/pkg-r/tests/testthat/test-page.R +++ b/pkg-r/tests/testthat/test-page.R @@ -198,13 +198,14 @@ test_that("page_react_html() rejects anything passed to ...", { tmp <- withr::local_tempfile(fileext = ".html") write_full_doc(tmp) - # A positional argument is reported by position... - expect_error(page_react_html(tmp, list()), "must be empty") - expect_error(page_react_html(tmp, list()), "..1", fixed = TRUE) - # ...and a misspelled name by name, which is the whole diagnosis. + # Asserted by rlang's condition class rather than its wording, which is + # rlang's to change; what is ours is that the check runs at all. + expect_error( + page_react_html(tmp, list()), + class = "rlib_error_dots_nonempty" + ) + # A misspelled name is named back, which is the whole diagnosis. expect_error(page_react_html(tmp, extra_dep = list()), "extra_dep") - # Plural agreement, and both labels present. - expect_error(page_react_html(tmp, 1, foo = 2), "Unexpected arguments") # The named arguments still work. expect_no_error(page_react_html(tmp, extra_deps = NULL)) From e515e64624bb07f293bac40a799abff07edfd986 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 31 Aug 2026 12:14:24 -0400 Subject: [PATCH 4/5] trim docs --- pkg-r/R/page.R | 4 +--- pkg-r/man/page_react_html.Rd | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg-r/R/page.R b/pkg-r/R/page.R index 581cbdb1..0312c217 100644 --- a/pkg-r/R/page.R +++ b/pkg-r/R/page.R @@ -143,9 +143,7 @@ page_react <- function( #' #' @param path Path to the HTML document. Defaults to `"www/index.html"`, #' relative to the working directory. -#' @param ... These dots are for future extensions and must be empty. They also -#' force every argument after them to be named, matching Python, where the -#' same arguments are keyword-only. +#' @param ... Ignored. #' @param extra_deps A list of additional [htmltools::htmlDependency] objects to #' render at the placeholder. A complete document has no tag tree to attach #' dependencies to, so this is the only way in — the counterpart of diff --git a/pkg-r/man/page_react_html.Rd b/pkg-r/man/page_react_html.Rd index bdc3c436..c5896466 100644 --- a/pkg-r/man/page_react_html.Rd +++ b/pkg-r/man/page_react_html.Rd @@ -15,9 +15,7 @@ page_react_html( \item{path}{Path to the HTML document. Defaults to \code{"www/index.html"}, relative to the working directory.} -\item{...}{These dots are for future extensions and must be empty. They also -force every argument after them to be named, matching Python, where the -same arguments are keyword-only.} +\item{...}{Ignored.} \item{extra_deps}{A list of additional \link[htmltools:htmlDependency]{htmltools::htmlDependency} objects to render at the placeholder. A complete document has no tag tree to attach From d7645ce4cffacc218081cbf4ecb95899f0387932 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 31 Aug 2026 12:22:20 -0400 Subject: [PATCH 5/5] fix(js): getShiny() must not throw when there is no window `getShiny()` read a bare `window`, but it is reached from debounce timers and event callbacks that can outlive the document. In CI the 01-hello example UI test finished before a 100 ms input debounce fired, so the timer ran after jsdom tore down and the bare identifier was a ReferenceError -- an unhandled error that failed the vitest run with all 277 tests passing. Guard with `typeof window`. Every caller already handles a missing Shiny with `?.` or an `if`, so returning undefined degrades the way they expect. Also covers a real browser case: a page or iframe unloading with a debounce pending. --- FEATURES.md | 8 +++++ pkg-js/dist/shinyreact.js | 2 +- .../shiny-react/__tests__/get-shiny.test.ts | 33 +++++++++++++++++++ pkg-js/src/shiny-react/get-shiny.ts | 12 ++++++- pkg-py/src/shinyreact/www/shinyreact.js | 2 +- pkg-r/inst/lib/shiny/shinyreact.js | 2 +- 6 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 pkg-js/src/shiny-react/__tests__/get-shiny.test.ts diff --git a/FEATURES.md b/FEATURES.md index 3d8fb307..2c24bb89 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -531,6 +531,14 @@ registries are exposed on `window.Shiny.reactRegistry`; the message registry on `{once: true}`, which would consume the event before Shiny existed and leave discovery uninstalled - it no-ops when there is no `document` +- `getShiny()` returns `undefined` rather than throwing when there is no + `window` at all, not just when Shiny has not loaded + - it is reached from debounce timers and event callbacks that can outlive the + document — a page unloading, or a jsdom test tearing down between the last + input write and its 100 ms debounce — where a bare `window` is a + `ReferenceError`, not `undefined` + - every caller already treats a missing Shiny as "do nothing", so this + degrades the way they expect - **both entry points install it** — `src/index.ts` (IIFE) and `src/npm.ts` (npm ESM), so both tiers get discovery and both send the bootstrap ping - the npm entry omitted it until #233, which left bundler-tier apps with no diff --git a/pkg-js/dist/shinyreact.js b/pkg-js/dist/shinyreact.js index 2acd5f19..b859a85c 100644 --- a/pkg-js/dist/shinyreact.js +++ b/pkg-js/dist/shinyreact.js @@ -1,4 +1,4 @@ -var um=Object.defineProperty;var am=(ee,nt,ue)=>nt in ee?um(ee,nt,{enumerable:!0,configurable:!0,writable:!0,value:ue}):ee[nt]=ue;var K=(ee,nt,ue)=>am(ee,typeof nt!="symbol"?nt+"":nt,ue);(function(){"use strict";function ee(t,l){for(var e=0;eu[a]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}function nt(){return window.Shiny}let ue=!1;function jh(){if(typeof document>"u"||ue)return;ue=!0;const t=()=>{const e=nt();e&&(e.addCustomMessageHandler("shinyreact-deps",async u=>{var a,n;try{const i=e;await((a=i.renderDependenciesAsync)==null?void 0:a.call(i,u)),await((n=i.bindAll)==null?void 0:n.call(i,document.documentElement))}catch(i){console.error("[shinyreact] failed to load pushed dependencies:",i)}}),e.initializedPromise.then(()=>{var u;(u=e.setInputValue)==null||u.call(e,".shinyreact_init:shinyreact.init",1)}))};if(nt()){t();return}const l=()=>{nt()&&(document.removeEventListener("shiny:connected",l),t())};document.addEventListener("shiny:connected",l)}function Vf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var jf={exports:{}},D={};/** +var um=Object.defineProperty;var am=(ee,nt,ue)=>nt in ee?um(ee,nt,{enumerable:!0,configurable:!0,writable:!0,value:ue}):ee[nt]=ue;var K=(ee,nt,ue)=>am(ee,typeof nt!="symbol"?nt+"":nt,ue);(function(){"use strict";function ee(t,l){for(var e=0;eu[a]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}function nt(){if(!(typeof window>"u"))return window.Shiny}let ue=!1;function jh(){if(typeof document>"u"||ue)return;ue=!0;const t=()=>{const e=nt();e&&(e.addCustomMessageHandler("shinyreact-deps",async u=>{var a,n;try{const i=e;await((a=i.renderDependenciesAsync)==null?void 0:a.call(i,u)),await((n=i.bindAll)==null?void 0:n.call(i,document.documentElement))}catch(i){console.error("[shinyreact] failed to load pushed dependencies:",i)}}),e.initializedPromise.then(()=>{var u;(u=e.setInputValue)==null||u.call(e,".shinyreact_init:shinyreact.init",1)}))};if(nt()){t();return}const l=()=>{nt()&&(document.removeEventListener("shiny:connected",l),t())};document.addEventListener("shiny:connected",l)}function Vf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var jf={exports:{}},D={};/** * @license React * react.production.js * diff --git a/pkg-js/src/shiny-react/__tests__/get-shiny.test.ts b/pkg-js/src/shiny-react/__tests__/get-shiny.test.ts new file mode 100644 index 00000000..54cda922 --- /dev/null +++ b/pkg-js/src/shiny-react/__tests__/get-shiny.test.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getShiny } from "../get-shiny"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete (window as any).Shiny; +}); + +describe("getShiny", () => { + it("returns window.Shiny when Shiny is present", () => { + const shiny = { setInputValue: vi.fn() }; + (window as any).Shiny = shiny; + + expect(getShiny()).toBe(shiny); + }); + + it("returns undefined when Shiny has not loaded yet", () => { + expect(getShiny()).toBeUndefined(); + }); + + it("returns undefined instead of throwing when there is no window", () => { + // A debounced input write can fire after the document is gone — a jsdom + // test tearing down, or a page unloading. Reading the bare `window` + // identifier there is a ReferenceError, which surfaces as an unhandled + // error and fails the whole run even though every test passed. + vi.stubGlobal("window", undefined); + + expect(() => getShiny()).not.toThrow(); + expect(getShiny()).toBeUndefined(); + }); +}); diff --git a/pkg-js/src/shiny-react/get-shiny.ts b/pkg-js/src/shiny-react/get-shiny.ts index de75160a..f7a42f95 100644 --- a/pkg-js/src/shiny-react/get-shiny.ts +++ b/pkg-js/src/shiny-react/get-shiny.ts @@ -1,8 +1,18 @@ import { type ShinyClassExtended } from "./index"; /** - * Get the Shiny object if it is available + * Get the Shiny object if it is available. + * + * `typeof window` rather than a bare `window`: this is reached from debounce + * timers and event callbacks that can outlive the document — a jsdom test + * tearing down between the last `setShinyInputValue` and its 100ms debounce, or + * a page/iframe unloading — and there the bare identifier is a `ReferenceError`, + * not `undefined`. Every caller already treats a missing Shiny as "do nothing", + * so returning `undefined` degrades exactly the way they expect. */ export function getShiny(): ShinyClassExtended | undefined { + if (typeof window === "undefined") { + return undefined; + } return window.Shiny; } diff --git a/pkg-py/src/shinyreact/www/shinyreact.js b/pkg-py/src/shinyreact/www/shinyreact.js index 2acd5f19..b859a85c 100644 --- a/pkg-py/src/shinyreact/www/shinyreact.js +++ b/pkg-py/src/shinyreact/www/shinyreact.js @@ -1,4 +1,4 @@ -var um=Object.defineProperty;var am=(ee,nt,ue)=>nt in ee?um(ee,nt,{enumerable:!0,configurable:!0,writable:!0,value:ue}):ee[nt]=ue;var K=(ee,nt,ue)=>am(ee,typeof nt!="symbol"?nt+"":nt,ue);(function(){"use strict";function ee(t,l){for(var e=0;eu[a]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}function nt(){return window.Shiny}let ue=!1;function jh(){if(typeof document>"u"||ue)return;ue=!0;const t=()=>{const e=nt();e&&(e.addCustomMessageHandler("shinyreact-deps",async u=>{var a,n;try{const i=e;await((a=i.renderDependenciesAsync)==null?void 0:a.call(i,u)),await((n=i.bindAll)==null?void 0:n.call(i,document.documentElement))}catch(i){console.error("[shinyreact] failed to load pushed dependencies:",i)}}),e.initializedPromise.then(()=>{var u;(u=e.setInputValue)==null||u.call(e,".shinyreact_init:shinyreact.init",1)}))};if(nt()){t();return}const l=()=>{nt()&&(document.removeEventListener("shiny:connected",l),t())};document.addEventListener("shiny:connected",l)}function Vf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var jf={exports:{}},D={};/** +var um=Object.defineProperty;var am=(ee,nt,ue)=>nt in ee?um(ee,nt,{enumerable:!0,configurable:!0,writable:!0,value:ue}):ee[nt]=ue;var K=(ee,nt,ue)=>am(ee,typeof nt!="symbol"?nt+"":nt,ue);(function(){"use strict";function ee(t,l){for(var e=0;eu[a]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}function nt(){if(!(typeof window>"u"))return window.Shiny}let ue=!1;function jh(){if(typeof document>"u"||ue)return;ue=!0;const t=()=>{const e=nt();e&&(e.addCustomMessageHandler("shinyreact-deps",async u=>{var a,n;try{const i=e;await((a=i.renderDependenciesAsync)==null?void 0:a.call(i,u)),await((n=i.bindAll)==null?void 0:n.call(i,document.documentElement))}catch(i){console.error("[shinyreact] failed to load pushed dependencies:",i)}}),e.initializedPromise.then(()=>{var u;(u=e.setInputValue)==null||u.call(e,".shinyreact_init:shinyreact.init",1)}))};if(nt()){t();return}const l=()=>{nt()&&(document.removeEventListener("shiny:connected",l),t())};document.addEventListener("shiny:connected",l)}function Vf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var jf={exports:{}},D={};/** * @license React * react.production.js * diff --git a/pkg-r/inst/lib/shiny/shinyreact.js b/pkg-r/inst/lib/shiny/shinyreact.js index 2acd5f19..b859a85c 100644 --- a/pkg-r/inst/lib/shiny/shinyreact.js +++ b/pkg-r/inst/lib/shiny/shinyreact.js @@ -1,4 +1,4 @@ -var um=Object.defineProperty;var am=(ee,nt,ue)=>nt in ee?um(ee,nt,{enumerable:!0,configurable:!0,writable:!0,value:ue}):ee[nt]=ue;var K=(ee,nt,ue)=>am(ee,typeof nt!="symbol"?nt+"":nt,ue);(function(){"use strict";function ee(t,l){for(var e=0;eu[a]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}function nt(){return window.Shiny}let ue=!1;function jh(){if(typeof document>"u"||ue)return;ue=!0;const t=()=>{const e=nt();e&&(e.addCustomMessageHandler("shinyreact-deps",async u=>{var a,n;try{const i=e;await((a=i.renderDependenciesAsync)==null?void 0:a.call(i,u)),await((n=i.bindAll)==null?void 0:n.call(i,document.documentElement))}catch(i){console.error("[shinyreact] failed to load pushed dependencies:",i)}}),e.initializedPromise.then(()=>{var u;(u=e.setInputValue)==null||u.call(e,".shinyreact_init:shinyreact.init",1)}))};if(nt()){t();return}const l=()=>{nt()&&(document.removeEventListener("shiny:connected",l),t())};document.addEventListener("shiny:connected",l)}function Vf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var jf={exports:{}},D={};/** +var um=Object.defineProperty;var am=(ee,nt,ue)=>nt in ee?um(ee,nt,{enumerable:!0,configurable:!0,writable:!0,value:ue}):ee[nt]=ue;var K=(ee,nt,ue)=>am(ee,typeof nt!="symbol"?nt+"":nt,ue);(function(){"use strict";function ee(t,l){for(var e=0;eu[a]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}function nt(){if(!(typeof window>"u"))return window.Shiny}let ue=!1;function jh(){if(typeof document>"u"||ue)return;ue=!0;const t=()=>{const e=nt();e&&(e.addCustomMessageHandler("shinyreact-deps",async u=>{var a,n;try{const i=e;await((a=i.renderDependenciesAsync)==null?void 0:a.call(i,u)),await((n=i.bindAll)==null?void 0:n.call(i,document.documentElement))}catch(i){console.error("[shinyreact] failed to load pushed dependencies:",i)}}),e.initializedPromise.then(()=>{var u;(u=e.setInputValue)==null||u.call(e,".shinyreact_init:shinyreact.init",1)}))};if(nt()){t();return}const l=()=>{nt()&&(document.removeEventListener("shiny:connected",l),t())};document.addEventListener("shiny:connected",l)}function Vf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var jf={exports:{}},D={};/** * @license React * react.production.js *