Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
60 changes: 51 additions & 9 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,9 +532,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`,
Expand Down Expand Up @@ -590,15 +591,23 @@ 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
`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
- the latch is **module-scoped**, so this holds per copy of the library, not
per page: two copies each install a `shinyreact-deps` handler and each send
Expand Down Expand Up @@ -671,6 +680,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
Expand All @@ -689,7 +720,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
Expand Down Expand Up @@ -730,7 +761,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 `<meta name="shiny-dependency-placeholder"
Expand Down Expand Up @@ -784,6 +815,15 @@ 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`, 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
Expand Down Expand Up @@ -814,7 +854,7 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except
- assets the document references must live in `www/`, which Shiny serves
statically — there is no `ReactApp`-style auto-mount

### `[py]` `set_react_page(path=None)` — Shiny Express only
### `[py]` `set_react_page(path=None, *, shinyreact_js="server")` — Shiny Express only

- no R counterpart: R has no Express-style page mechanism
- it sets the Express page via `page_opts(page_fn=...)`
Expand Down Expand Up @@ -846,7 +886,7 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except
`page_opts(title=...)` + `set_react_page()` a startup `TypeError:
_react_page_fn() got an unexpected keyword argument 'title'`

### `[py]` `ReactApp(server, *, ui=None, static_assets=MISSING, bookmark_store="disable", **kwargs)`
### `[py]` `ReactApp(server, *, ui=None, static_assets=MISSING, bookmark_store="disable", shinyreact_js="server", **kwargs)`

- a `shiny.App` subclass whose UI is discovered, not passed — the app file is
just the server
Expand All @@ -862,6 +902,8 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except
restore works with no further wiring
- the mode is re-checked **per request**, so creating or deleting
`www/index.html` during a dev session switches modes with no restart
- `shinyreact_js=` is forwarded into **both** discovered modes; it is
ignored when `ui=` is passed, since that UI is already built
- `bookmark_store=` is an explicit parameter, forwarded to `shiny.App`
- `ui=page_react_html(...)` plus a non-`"disable"` store raises `TypeError`,
naming both fixes: drop `ui=`, or pass `ui=lambda request:
Expand Down
23 changes: 16 additions & 7 deletions decisions/2026-08-17-js-distribution.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Shipping the JS runtime: npm package + HTMLDependency hybrid

**Date:** 2026-08-17
**Status:** Decided; in progress — config tag + handshake (#198), page modes (#208, #214), protocol doc + dual build + publish workflow (npm-package PR). Remaining: first npm publish, 09-hmr conversion (needs a server-side switch to omit the IIFE bundle for npm-tier pages)
**Status:** Decided; in progress — config tag + handshake (#198), page modes (#208, #214), protocol doc + dual build + publish workflow (npm-package PR), `shinyreact_js=` switch + 09-hmr conversion (#217). Remaining: first npm publish (until then `examples/09-hmr` depends on `file:../../pkg-js`)
**Issues:** [#172](https://github.com/posit-dev/shinyreact/issues/172) (spike), [#28](https://github.com/posit-dev/shinyreact/issues/28) (upstream npm publication)

## Context
Expand Down Expand Up @@ -97,7 +97,11 @@ offline.
*Cons:* disqualifying DX — `file:` deps bake machine-specific absolute paths
into `package.json`/lockfiles, breaking every collaborator, CI runner, and
venv/renv rebuild; opaque to Renovate/audit/caching; still needs a separate
no-build answer. **Investigated and rejected.**
no-build answer. **Investigated and rejected** — but reopened for
reconsideration in #261, on the grounds that an *in-project* environment
(uv's `.venv/`, renv's `renv/library/`) makes the path project-relative and
therefore portable, which is the property this rejection assumed was
unavailable.

**D. Server-served ESM + import maps** — server ships an ESM build and injects
an import map; bundlers mark the runtime external.
Expand Down Expand Up @@ -166,12 +170,14 @@ The rule, then:
distinction and must be installed by both. To stay import-time safe, such an
installer must no-op without `document` (SSR, node tests), do nothing beyond
registering a listener until Shiny exists, and tolerate being called twice.
- **Tier distinctions are deliberate and few.** Only two today: the IIFE
installs `window.shinyreact` (npm consumers import the hooks directly), and
the npm build treats a missing `#shinyreact-config` tag as fatal (an
- **Tier distinctions are deliberate and few.** Three today: the IIFE
installs `window.shinyreact` (npm consumers import the hooks directly); the
npm build treats a missing `#shinyreact-config` tag as fatal (an
independently installed client meeting a tagless page means the server
predates the protocol; the IIFE ships *with* the server, so absence is
legitimate for a hand-wired `page_bare()` page).
legitimate for a hand-wired `page_bare()` page); and the npm build warns when
`window.shinyreact` is already present, since only it can observe the
double-load — script order guarantees the IIFE ran first (#217).
- **Both are pinned by tests.** `pkg-js/src/__tests__/entry-parity.test.ts`
imports each entry for real and asserts its observable side effects, including
the deliberate differences. Comments do not stop drift; that test does.
Expand Down Expand Up @@ -200,7 +206,10 @@ The rule, then:
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.
`@posit/shinyreact`; retire its dev/prod bridge alias. Done in #217, which
also added the `shinyreact_js="client"` page-entry-point switch the
conversion needs — an npm-tier page must not *also* be served the IIFE
bundle.
5. Longer term, if the zero-build tier is ever deprecated, the global dies
with it — completing #172's original goal.

Expand Down
36 changes: 21 additions & 15 deletions examples/09-hmr/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,35 @@ a unit test; `(verify)` marks a claim not yet checked against the code.
## Server (`app.py`, Express)

- one output, `doubled` → `input.count() * 2`
- `set_react_page()` with no arguments — the same discovery as the no-build
examples; nothing in the server knows about the dev server
- `set_react_page(shinyreact_js="client")` — the npm tier: the client bundles
shinyreact.js, so the server serves the `#shinyreact-config` tag but **not**
shinyreact.js/.css; nothing in the server knows about the dev server
- `[py]` only — this example has no R server

## npm tier

- the only example that imports `@posit/shinyreact` instead of destructuring
`window.shinyreact`; the built `www/ui.js` contains no `window.shinyreact`
reference at all
- until the first npm publish, the dependency is `file:../../pkg-js`, so
`pkg-js` must be built (`npm run build`) before this example installs
- React is bundled by this example in **both** modes — a development React in
dev, which is what Fast Refresh requires and what the production-only React
inside the IIFE bundle could never provide
- `resolve.dedupe: ["react", "react-dom"]` keeps `App.tsx` and the hooks on one
React copy — the `file:` dep is symlinked and brings its own `node_modules`
- `server.fs.allow` is widened to the repo root so the symlinked package
outside this directory can be served in dev

## Two modes, one entry

- `vite build` → `www/ui.js` is the real IIFE bundle, React externalized to
`window.shinyreact`, `NODE_ENV=production`
- `vite build` → `www/ui.js` is the real IIFE bundle, `NODE_ENV=production`
- `vite` (dev) → `www/ui.js` is a **stub** that boots the dev server's modules;
`NODE_ENV=development`
- `www/ui.js` is gitignored, so a fresh checkout must run one of the two before
`shiny run` serves anything
- the switch is `resolve.alias["shiny-bridge"]`, chosen by Vite's `command`
- `serve` → `src/shiny-bridge.dev.ts`, re-exporting the hooks from the
repo's vendored `pkg-js/src/shiny-react/` source, bundled with this
example's own **development** React (Fast Refresh cannot run against the
production React inside `window.shinyreact`)
- `build` → `src/shiny-bridge.prod.ts`, reading the hooks off
`window.shinyreact`
- `resolve.dedupe: ["react", "react-dom"]` keeps `App.tsx` and the vendored
hooks on one React copy in dev
- `server.fs.allow` is widened to the repo root so the vendored source
outside this directory can be served
- there is no dev/prod hook switch any more: both modes import
`@posit/shinyreact`, so no `shiny-bridge` alias and no rollup externals

## The dev stub (`vite-dev-stub.js`)

Expand Down
24 changes: 20 additions & 4 deletions examples/09-hmr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ A `ui.tsx`-pattern example that gives you **React Fast Refresh** while Shiny
serves the page: edit a component and it hot-swaps in place, keeping component
state — no full page reload, no `vite build` wait.

This is the **npm-tier** example: the client imports the hooks from
`@posit/shinyreact` and bundles its own React, in both modes. That is what
makes Fast Refresh possible — Fast Refresh needs a *development* React build,
and the React inside the server-shipped IIFE bundle is production-only.

Because the client ships the runtime itself, the server must not serve
`shinyreact.js` too — two copies on one page. Hence
`set_react_page(shinyreact_js="client")` in `app.py`. The `#shinyreact-config`
tag is still emitted, and the npm client requires it.

Until `@posit/shinyreact` is published, `package.json` depends on it as
`file:../../pkg-js` — repo-relative, so it only resolves inside a checkout of
this repo. Copy this example elsewhere and that line becomes
`"@posit/shinyreact": "^<version>"` 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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion examples/09-hmr/app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/09-hmr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion examples/09-hmr/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
12 changes: 0 additions & 12 deletions examples/09-hmr/src/shiny-bridge.dev.ts

This file was deleted.

9 changes: 0 additions & 9 deletions examples/09-hmr/src/shiny-bridge.prod.ts

This file was deleted.

Loading