From 6e155f30d5ad2500a5907ea04ce713c53a762de5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 17 Aug 2026 12:08:04 -0400 Subject: [PATCH 1/5] docs: record JS distribution decision (#172) - Add decisions/2026-08-17-js-distribution.md: hybrid npm runtime (@posit/shinyreact) + HTMLDependency zero-build tier, protocol handshake via a #shinyreact-config script tag - Update DESIGN.md and conf-2026 goals to the @posit/shinyreact name --- DESIGN.md | 4 +- decisions/2026-08-17-js-distribution.md | 182 ++++++++++++++++++++++++ docs/posit-conf-2026-goals.md | 2 +- 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 decisions/2026-08-17-js-distribution.md diff --git a/DESIGN.md b/DESIGN.md index 8f1d781..b3aedc3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -140,7 +140,7 @@ my-app/ styles.css ``` -The author owns `app.py`. Everything else is generated by AI (Claude or equivalent), typically via a Claude Skill or similar tooling that understands Shiny's client-server bridge. The `package.json` declares dependencies on the Shiny client runtime (`@posit/shiny`), the bridge hooks (`@posit/shiny-react`), React, and any component libraries. The build step (managed by `shiny run` or equivalent) resolves dependencies and bundles `src/` into `dist/`. +The author owns `app.py`. Everything else is generated by AI (Claude or equivalent), typically via a Claude Skill or similar tooling that understands Shiny's client-server bridge. The `package.json` declares dependencies on the Shiny client runtime (`@posit/shiny`), the bridge hooks (`@posit/shinyreact`), React, and any component libraries. The build step (managed by `shiny run` or equivalent) resolves dependencies and bundles `src/` into `dist/`. ### What the server file contains @@ -287,7 +287,7 @@ In ui.tsx-first apps, `shiny run` should explicitly disable HTMLDependency injec ### Shiny client runtime as an npm package -`shiny.js` and the `shiny-react` bridge hooks need to be published as `@posit/shiny` and `@posit/shiny-react` so client bundlers can resolve them via `package.json` instead of HTMLDependency injection — replacing IIFE bundles, `window.shinyreact` globals, and ad-hoc React deduplication. Significant upstream ask to the Shiny team. Tracked in [#28](https://github.com/posit-dev/shinyreact/issues/28). +`shiny.js` and the bridge hooks need to be published as `@posit/shiny` and `@posit/shinyreact` so client bundlers can resolve them via `package.json` instead of HTMLDependency injection — replacing IIFE bundles, `window.shinyreact` globals, and ad-hoc React deduplication. Significant upstream ask to the Shiny team. Tracked in [#28](https://github.com/posit-dev/shinyreact/issues/28); the hybrid distribution plan (npm runtime alongside the HTMLDependency zero-build tier) is decided in `decisions/2026-08-17-js-distribution.md`. ### `shiny run` build integration diff --git a/decisions/2026-08-17-js-distribution.md b/decisions/2026-08-17-js-distribution.md new file mode 100644 index 0000000..90d9875 --- /dev/null +++ b/decisions/2026-08-17-js-distribution.md @@ -0,0 +1,182 @@ +# Shipping the JS runtime: npm package + HTMLDependency hybrid + +**Date:** 2026-08-17 +**Status:** Decided, not yet implemented +**Issues:** [#172](https://github.com/posit-dev/shinyreact/issues/172) (spike), [#28](https://github.com/posit-dev/shinyreact/issues/28) (upstream npm publication) + +## Context + +Today the entire JS runtime ships as a self-contained IIFE inside the Python +and R packages, injected as an HTMLDependency, with its whole public surface on +`window.shinyreact` (`js/src/global.ts`, `pkg-py/src/shinyreact/_dep.py`, +`pkg-r/R/dep.R`). Issue #172 asked whether we can distribute via npm instead +and drop the global, without letting the JS and the Python/R servers drift out +of sync. + +The current architecture provides four guarantees by construction, and any +replacement has to account for each: + +1. **Exactly one React instance.** React 19 is inside the IIFE; downstream + bundles externalize `react`/`react-dom` to `window.shinyreact.React` + (`examples/04-shadcn/vite.config.js`). No npm hoisting or peer-dep + resolution can produce two Reacts. +2. **A zero-build tier.** Half the example gallery is plain `.js` in `www/` + destructuring `window.shinyreact` — no `package.json`, no bundler. +3. **No client/server drift.** The hooks the browser runs ship inside the + pip/CRAN package, so installing the server package pins the client. +4. **A load-order contract for bookmark restore.** The server emits a head + `` + and both tiers read it via `document.getElementById`. This removes the only + structural dependency on `window.shinyreact` existing before app code runs, + and gives the handshake its delivery vehicle. This change is independent of + npm publishing and should land first. + +4. **Semver protocol handshake at hook boot.** `@posit/shinyreact` declares a + supported protocol range (e.g. `^1`); on init it checks the + server-rendered `protocolVersion` and fails fast with a message naming both + versions and the fix. The protocol version bumps only when the wire format + changes — npm and PyPI/CRAN releases do not need lockstep. + +5. **Protocol as an artifact.** Write the wire contract (custom message + shapes, input-handler suffixes, restore/config payload) down as a single + versioned schema document, with the same fixture payloads validated in TS, + Python, and R. Its semver *is* the `protocolVersion`, so "what counts as a + breaking change" is answered by diffing the schema. This is also the class + of test that would have caught the #182–#186 parity bugs earlier. + +### Accepted trade-offs + +- **Two shipped artifacts** (npm ESM + IIFE). Mitigated: single source, one + release builds both; docs must be crisp about which tier an app is in. +- **Drift between an old npm install and a new server package is possible.** + The handshake makes it a clear, actionable error instead of silent + breakage — the best any dual-registry design achieves (ipywidgets, plotly, + bokeh included). +- **An npm release pipeline** is new operational surface (publish workflow, + `NPM_TOKEN`, versioning discipline). +- **Single-React for the npm tier** rests on standard peer-dependency + semantics rather than construction. Standard for the React ecosystem; + the IIFE tier keeps the by-construction guarantee. + +## Consequences / sequencing + +1. `#shinyreact-config` script tag (restore + protocol version) — lands in + this repo now; both `_bookmark.py` and `bookmark.R` change, plus + `global.ts` reading the tag instead of `_restore`. +2. Protocol schema document + cross-language fixture tests. +3. Package `js/src/` for dual output (ESM + IIFE); add publish workflow for + `@posit/shinyreact`. +4. Convert one Vite example (`09-hmr` is the natural candidate) to import + `@posit/shinyreact`; retire its dev/prod bridge alias. +5. Longer term, if the zero-build tier is ever deprecated, the global dies + with it — completing #172's original goal. + +Related: `DESIGN.md` "Shiny client runtime as an npm package" (#28) and +`docs/posit-conf-2026-goals.md` (splitting the vendored hooks into their own +repo) both point at this end state; this record supersedes the +`@posit/shiny-react` naming used there. diff --git a/docs/posit-conf-2026-goals.md b/docs/posit-conf-2026-goals.md index 5f29f95..bfd5bde 100644 --- a/docs/posit-conf-2026-goals.md +++ b/docs/posit-conf-2026-goals.md @@ -45,7 +45,7 @@ Remaining known issues are tracked in the [GitHub issue tracker](https://github. The vendored `shiny-react` code at `pkg-js/src/shiny-react/` and the broader Shiny JS infrastructure should live in its own repository, independent of `shinyreact`. This enables: -- Other packages (not just `shinyreact`) to depend on shiny-react +- Other packages (not just `shinyreact`) to depend on the published `@posit/shinyreact` npm package (naming decided in `decisions/2026-08-17-js-distribution.md`) - Independent versioning and release cycle - Cleaner separation of concerns between Shiny's JS plumbing and `shinyreact`'s rendering From ccf47fda34bd39c7eb3bab9754155305ee10600a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 17 Aug 2026 12:13:48 -0400 Subject: [PATCH 2/5] docs: update decision record paths for pkg-js rename --- decisions/2026-08-17-js-distribution.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/decisions/2026-08-17-js-distribution.md b/decisions/2026-08-17-js-distribution.md index 90d9875..04544e2 100644 --- a/decisions/2026-08-17-js-distribution.md +++ b/decisions/2026-08-17-js-distribution.md @@ -8,7 +8,7 @@ Today the entire JS runtime ships as a self-contained IIFE inside the Python and R packages, injected as an HTMLDependency, with its whole public surface on -`window.shinyreact` (`js/src/global.ts`, `pkg-py/src/shinyreact/_dep.py`, +`window.shinyreact` (`pkg-js/src/global.ts`, `pkg-py/src/shinyreact/_dep.py`, `pkg-r/R/dep.R`). Issue #172 asked whether we can distribute via npm instead and drop the global, without letting the JS and the Python/R servers drift out of sync. @@ -114,7 +114,7 @@ payoff); would ship React ESM builds inside pip/CRAN packages. Adopt **option E**, with the npm package named **`@posit/shinyreact`** (not `@posit/shiny-react` — name consistency with the Python/R packages; the -upstream `@posit/shiny-react`@0.0.16 that `js/src/shiny-react/` was vendored +upstream `@posit/shiny-react`@0.0.16 that `pkg-js/src/shiny-react/` was vendored from is a separate, frozen artifact). 1. **Publish `@posit/shinyreact` to npm as the real runtime** — the hooks and @@ -169,7 +169,7 @@ from is a separate, frozen artifact). this repo now; both `_bookmark.py` and `bookmark.R` change, plus `global.ts` reading the tag instead of `_restore`. 2. Protocol schema document + cross-language fixture tests. -3. Package `js/src/` for dual output (ESM + IIFE); add publish workflow for +3. Package `pkg-js/src/` for dual output (ESM + IIFE); add publish workflow for `@posit/shinyreact`. 4. Convert one Vite example (`09-hmr` is the natural candidate) to import `@posit/shinyreact`; retire its dev/prod bridge alias. From ff5a0bb3c878c4c48f469076c495fd20baac2315 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Mon, 17 Aug 2026 12:28:10 -0400 Subject: [PATCH 3/5] feat: ship a #shinyreact-config tag with protocol handshake (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Page entry points now always emit " }); + }); + + it("logs and returns null on malformed JSON instead of throwing", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + setConfigTag("{not json"); + expect(readShinyReactConfig()).toBeNull(); + expect(err).toHaveBeenCalled(); + }); +}); + +describe("assertProtocolCompatible", () => { + it("accepts the same major version", () => { + const major = PROTOCOL_VERSION.split(".")[0]; + expect(() => assertProtocolCompatible(`${major}.999`)).not.toThrow(); + }); + + it("throws on a different major version, naming both versions", () => { + expect(() => assertProtocolCompatible("999.0")).toThrowError( + new RegExp(`999\\.0[\\s\\S]*${PROTOCOL_VERSION.replace(".", "\\.")}`), + ); + }); +}); + +describe("PROTOCOL_VERSION parity", () => { + it("matches the Python and R declarations", () => { + // PROTOCOL_VERSION is one contract declared in three languages; this + // parity test pins all three to the same string. Mirrors Python's + // test_protocol_version_matches_js_and_r and R's "protocol version + // matches the JS and Python declarations". + let repoRoot = process.cwd(); + while (!existsSync(join(repoRoot, "pkg-py")) && dirname(repoRoot) !== repoRoot) { + repoRoot = dirname(repoRoot); + } + const pySrc = join(repoRoot, "pkg-py", "src", "shinyreact", "_protocol.py"); + const rSrc = join(repoRoot, "pkg-r", "R", "protocol.R"); + if (!existsSync(pySrc) || !existsSync(rSrc)) { + // Monorepo sources not available — nothing to compare against. + return; + } + const pyMatch = readFileSync(pySrc, "utf8").match( + /PROTOCOL_VERSION = "([^"]+)"/, + ); + const rMatch = readFileSync(rSrc, "utf8").match( + /\.protocol_version <- "([^"]+)"/, + ); + expect(pyMatch?.[1]).toBe(PROTOCOL_VERSION); + expect(rMatch?.[1]).toBe(PROTOCOL_VERSION); + }); +}); diff --git a/pkg-js/src/shiny-react/__tests__/use-shiny-restore.test.tsx b/pkg-js/src/shiny-react/__tests__/use-shiny-restore.test.tsx index ff2a07d..0b859da 100644 --- a/pkg-js/src/shiny-react/__tests__/use-shiny-restore.test.tsx +++ b/pkg-js/src/shiny-react/__tests__/use-shiny-restore.test.tsx @@ -17,6 +17,16 @@ function freshWindow(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).window = (globalThis as any).window || {}; delete (globalThis as any).window.shinyreact; + document.getElementById("shinyreact-config")?.remove(); +} + +/** Insert the `#shinyreact-config` tag the way the server emits it. */ +function setConfigTag(payload: object): void { + const el = document.createElement("script"); + el.type = "application/json"; + el.id = "shinyreact-config"; + el.textContent = JSON.stringify(payload).replace(/ { @@ -24,6 +34,50 @@ describe("applyRestoredValues", () => { freshWindow(); }); + it("seeds registry entries from the #shinyreact-config tag", () => { + setConfigTag({ protocolVersion: "1.0", restore: { foo: "hello", num: 42 } }); + const registry = new InputRegistry(); + + applyRestoredValues(registry); + + expect(registry.get("foo")?.getValue()).toBe("hello"); + expect(registry.get("num")?.getValue()).toBe(42); + expect((window as any).shinyreact._restore).toEqual({ + "-applied": true, + "-values": { foo: "hello", num: 42 }, + }); + }); + + it("config tag restore takes precedence over the legacy _restore global", () => { + setConfigTag({ protocolVersion: "1.0", restore: { foo: "from-config" } }); + (window as any).shinyreact = { _restore: { foo: "from-legacy" } }; + const registry = new InputRegistry(); + + applyRestoredValues(registry); + + expect(registry.get("foo")?.getValue()).toBe("from-config"); + }); + + it("config tag without restore writes the empty sentinel", () => { + setConfigTag({ protocolVersion: "1.0" }); + const registry = new InputRegistry(); + + applyRestoredValues(registry); + + expect(registry.size()).toBe(0); + expect((window as any).shinyreact._restore).toEqual({ + "-applied": true, + "-values": {}, + }); + }); + + it("throws on a protocol major-version mismatch, naming both versions", () => { + setConfigTag({ protocolVersion: "999.0", restore: { foo: "hello" } }); + const registry = new InputRegistry(); + + expect(() => applyRestoredValues(registry)).toThrowError(/999\.0/); + }); + it("seeds registry entries from window.shinyreact._restore and replaces it with sentinel", () => { (window as any).shinyreact = { _restore: { foo: "hello", num: 42 } }; const registry = new InputRegistry(); diff --git a/pkg-js/src/shiny-react/bookmark.ts b/pkg-js/src/shiny-react/bookmark.ts index 17331a9..928a03a 100644 --- a/pkg-js/src/shiny-react/bookmark.ts +++ b/pkg-js/src/shiny-react/bookmark.ts @@ -1,49 +1,61 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { type InputRegistry } from "./input-registry"; +import { assertProtocolCompatible, readShinyReactConfig } from "./config"; /** * Adopt bookmarked input values into the input registry. * - * Reads `window.shinyreact._restore` (set by a head "}) - with restore_context_cm(ctx): - dep = _restore_script_tag() - assert dep is not None - head_html = _render_dep_to_head(dep) - # The literal "" sequence inside the JSON payload must be escaped - # so the browser does not see it as ending the script. The escaping replaces - # " (the one closing our injected tag). +def test_config_script_tag_escapes_closing_script_tag() -> None: + values = {"foo": ""} + head_html = _config_html(values) + # Every "<" in the payload is emitted as the JSON escape \\u003c, so the only actual + # is the one closing our injected tag, and no ") == 1 - # And the value still round-trips correctly back through the escape. - assert _extract_restore_payload(head_html) == { - "foo": "" - } + assert "", html, fixed = TRUE))), 1L ) + expect_no_match(html, "