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
33 changes: 17 additions & 16 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ R has no e2e suite, so no `(e2e)` leaf covers R (issue #194).
reported is that client and server cannot talk
- one element reused across repeated failures; the newest message wins
- the throw is unchanged (fail fast); the banner is additive
- covers all three fatal paths: major mismatch (either direction), missing
tag in the npm build, and a tag with no `protocolVersion` in the npm build
- covers the only fatal path there is: a major-version mismatch (either
direction). A missing tag, or a tag with no `protocolVersion`, is not
fatal in either build
- server → client boot config: one `<script type="application/json"
id="shinyreact-config">` tag
- it lands in `<head>` in every language and on every path
Expand All @@ -95,9 +96,8 @@ R has no e2e suite, so no `(e2e)` leaf covers R (issue #194).
dependency's `head` HTML, with `all_files = FALSE` since it ships no files
- R emitted it inline in `<body>` until #224
- always carries `protocolVersion`
- `[js]` a tag *without* one no longer skips the handshake silently: the
IIFE logs an error, and the npm build throws, since an independently
installed client cannot assume compatibility
- `[js]` a tag *without* one does not skip the handshake silently: both
builds log an error naming what could not be verified
- carries `restore` (an `{inputId: value}` map) only when a bookmark restore
is active *and* the map is non-empty
- emitted by every page entry point except `page_bare()`
Expand All @@ -106,12 +106,11 @@ R has no e2e suite, so no `(e2e)` leaf covers R (issue #194).
- the reader logs and returns `null` on malformed JSON rather than
throwing — a broken config must not take down an app that never bookmarks
- the reader returns `null` when there is no `document` (non-DOM env)
- `[js]` whether a *missing* tag is fatal depends on which build is running
- IIFE bundle (shipped inside the R/Python packages): tolerated, because a
hand-wired `page_bare()` page legitimately has no tag
- npm ESM build (`@posit-dev/shinyreact`): fatal, opted into at import time —
an independently installed client meeting a tagless page means the server
predates the protocol
- `[js]` a *missing* tag is tolerated by both builds — IIFE and npm ESM
- a `page_bare()` page legitimately has none, at either tier — see
`examples/11-npm-local`, an npm-tier app with no tag on the page
- the npm build treated absence as fatal until #261, when the opt-in
strict mode was removed entirely
- server → client custom message: `shinyReactMessage`, payload `{id, data}`
- client → server input values: the wire id may carry a `:<type>` suffix naming
a server-side input handler
Expand Down Expand Up @@ -609,9 +608,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 three *deliberate* tier differences (only the
IIFE installs `window.shinyreact`; only the npm build treats a missing
config tag as fatal; only the npm build warns about a double load)
side effects — including the two *deliberate* tier differences (only the
IIFE installs `window.shinyreact`; only the npm build warns about a double
load) and that neither entry treats a missing config tag as fatal
- 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 @@ -693,7 +692,8 @@ the shinyreact bundle dependency and the `#shinyreact-config` tag — except

- 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
emitted, since it carries the protocol version and any bookmark restore
payload
- it is for the npm tier: a client importing `@posit-dev/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
Expand Down Expand Up @@ -1355,7 +1355,8 @@ initial page.
leaving `ImageOutput`'s spinner without its `@keyframes spin`)
- Vite does not inject a CSS import into a lib-mode ESM bundle, so
consumers opt in rather than having it forced on them
- a missing `#shinyreact-config` tag is a hard error, opted into at import
- a missing `#shinyreact-config` tag is tolerated, as in the IIFE bundle, so
a `page_bare(page_react_dep())` page boots at the npm tier (#261)
- `window.shinyreact` contains exactly: `useShinyInput`, `useShinyInputValue`,
`useSetShinyInput`, `useShinyOutputValue`, `useShinyOutputStatus`,
`useShinyOutputError`, `useShinyMessageHandler`, `useShinyInitialized`,
Expand Down
18 changes: 9 additions & 9 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), `shinyreact_js=` switch + 09-hmr conversion (#217). Remaining: first npm publish (until then `examples/09-hmr` depends on `file:../../pkg-js`)
**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), `page_bare()` at the npm tier + removal of the config-tag strict mode (#261, `examples/11-npm-local`). Remaining: first npm publish (until then `examples/09-hmr` and `examples/11-npm-local` depend 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 @@ -170,14 +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.** 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); 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).
- **Tier distinctions are deliberate and few.** Two today: the IIFE installs
`window.shinyreact` (npm consumers import the hooks directly), 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).
A third — the npm build treating a missing `#shinyreact-config` tag as fatal
— was dropped in #261: it assumed a client installed independently of the
server, which is exactly what installing from the server package undoes, and
it banned the tagless `page_bare(page_react_dep())` page.
- **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
3 changes: 2 additions & 1 deletion examples/09-hmr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@

# npm tier: the client imports `@posit-dev/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.
# `#shinyreact-config` tag is still emitted: it carries the protocol version
# and any bookmark restore payload.
set_react_page(shinyreact_js="client")


Expand Down
4 changes: 4 additions & 0 deletions examples/11-npm-local/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
package-lock.json
www/ui.js
www/ui.css
78 changes: 78 additions & 0 deletions examples/11-npm-local/FEATURES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# examples/11-npm-local — behavior

Old Faithful histogram at the npm tier with nothing else on the page: the
client imports `@posit-dev/shinyreact`, and the server injects no shinyreact JS and
no config tag. Two interchangeable servers (`app.py`, `app.R`) over one `www/`
client.

Every leaf below is one checkable claim about this app. `[py]` / `[r]` / `[js]`
mark a claim that holds only in that language; `(test)` marks a claim pinned by
a unit test; `(verify)` marks a claim not yet checked against the code.

## Client distribution

- `package.json` depends on `"@posit-dev/shinyreact": "file:../../pkg-js"` — the
repo-relative placeholder `examples/09-hmr` uses until the first npm publish
- `pkg-js` must be built (`make js-build`) before `npm install`
- nothing machine-specific reaches `package.json` or the lockfile
- React and ReactDOM are the app's own devDependencies, bundled into
`www/ui.js`

## Page

- the page carries this app's dependency, named `npm-local`, and no
`shinyreact` dependency `(test)`
- so there is exactly one React and one copy of the hooks on the page
- the page has no `#shinyreact-config` tag `(test)`
- `page_bare()` is the one page entry point that never emits it
- therefore no protocol handshake runs; the client tolerates its absence
- bookmark restore is unavailable in this shape; this app does not bookmark
- contrast `examples/09-hmr`, the other npm-tier example: it uses
`set_react_page(shinyreact_js="client")`, which omits the JS but still
emits the tag
- title is `"Old Faithful"`, set explicitly (`page_bare(title=)`), not derived
from the folder name
- `www/ui.js` and `www/ui.css` are served by the dependency, versioned by
`ui.js`'s mtime; neither is committed, and neither is `www/` itself
- `[py]` `app.py` builds the bundle on first run when `www/ui.js` is absent
(`npm install` + `npm run build`, preceded by the same in `pkg-js/` when
`dist-npm/` is missing) — same as `examples/09-hmr`
- `[r]` `app.R` does not: a fresh clone gets `page_react_dep()`'s "React
asset directory not found" error, which names the fix
- the tests never import `app.py` or source `app.R`, so neither triggers a
build; each rebuilds the two-line ui against an empty temp directory

## Server

- output `dist_data` → `{breaks: number[], counts: number[]}`
- `[py]` hand-written equal-width binner in `app.py`, stdlib only; data read
from `faithful.csv` next to it, column `waiting`
- `[r]` `hist(waiting, breaks = seq(...), plot = FALSE)` over base R's
`faithful$waiting`
- `[r]` vectors are wrapped in `I()` so a one-bin result serializes as a JSON
array, not a scalar
- output `dist_caption` → `"272 eruptions in N bins"`, singular `"bin"` when
`N == 1`
- before the client's first `bins` message
- `[py]` `input.bins()` raises a silent exception, so neither output produces
a value
- `[r]` `input$bins` is `NULL` and both outputs return `NULL` explicitly

## Client (`src/ui.jsx`)

- JSX, built by Vite in lib/IIFE mode to `www/ui.js`
- nothing is externalized: React, ReactDOM and the hooks are all bundled
- `import "@posit-dev/shinyreact/styles"` and `./ui.css` are emitted as
`www/ui.css` (`assetFileNames: "ui.[ext]"`)
- mounts into a `<div>` the client appends to `<body>`; the page ships no
container
- bins slider: `useShinyInput("bins", 30)`, `<input type="range">`, `min` 1,
`max` 50, `id="bins"`; current value echoed in the label
- histogram drawn as SVG: one `<rect fill="#447099">` per count, viewBox
`0 0 620 320`, `role="img"` with `aria-label` `"Histogram of Old Faithful
waiting times in N bins"`
- no `dist_data` value yet → a `.placeholder` div reading `"Loading…"`
- value present and `useShinyOutputStatus("dist_data") === "recalculating"` →
the SVG stays mounted and gets `class="recalculating"` (`opacity: 0.6`)
- caption paragraph shows `dist_caption`, or a single space before the first
value arrives
71 changes: 71 additions & 0 deletions examples/11-npm-local/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 11-npm-local — the npm tier with nothing else on the page

The Old Faithful app again ([01-hello](../01-hello/) does it with no build
step), rebuilt to show the smallest possible npm-tier app: the client imports
`@posit-dev/shinyreact` and the server sends **no shinyreact JS and no
`#shinyreact-config` tag at all**.

## What is different here

[09-hmr](../09-hmr/) is the other npm-tier example, and it uses
`set_react_page(shinyreact_js="client")` — a page entry point that still emits
the config tag (it carries the protocol version, and bookmark restore rides on
it). This app needs neither, so its whole page is:

```python
ui = page_bare(page_react_dep(src_dir=_APP_DIR / "www", name="npm-local"))
```

```r
ui <- page_bare(page_react_dep("www", name = "npm-local"))
```

Shiny's own dependencies plus this app's bundle. That means the page has

- **no `shinyreact.js`** — no second copy of React and the hooks, and no
duplicate `shinyReactMessage` handler;
- **no `#shinyreact-config` tag, so no protocol handshake** — nothing on the
page is versioned separately from the app that shipped it.

Bookmark restore *does* travel through the config tag, so an app that bookmarks
wants `page_react()` instead. This app does not bookmark.

The hooks come from an import, not from `window.shinyreact`:

```jsx
import { useShinyInput, useShinyOutputValue } from "@posit-dev/shinyreact";
```

Until the first npm publish that package is the repo-relative
`file:../../pkg-js`, the same placeholder [09-hmr](../09-hmr/) uses — build
`pkg-js` (`make js-build`) before `npm install`. Nothing machine-specific
reaches `package.json` or the lockfile.

## Run it

```bash
shiny run app.py # Python — builds the bundle on first run
```

`app.R` does not build anything, so build once before running it:

```bash
make js-build # from the repo root; pkg-js/dist-npm/
npm install
npm run build # → www/ui.js, www/ui.css

Rscript -e 'shiny::runApp(".")' # R
```

`npm run dev` rebuilds on change; reload the page to see it (no HMR here —
that is [09-hmr](../09-hmr/)).

## Tests

```bash
pytest # tests/test_page.py
Rscript -e 'shiny::runTests()' # tests/testthat/test-page.R
```

Both assert the same thing in each language: the page carries this app's
dependency and no shinyreact one, and has no config tag.
45 changes: 45 additions & 0 deletions examples/11-npm-local/app.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
library(shiny)
library(shinyreact)

waiting <- faithful$waiting

# The whole page: Shiny's own dependencies plus this app's bundle, served as
# an htmlDependency out of www/. No shinyreact JS is injected — the client
# runtime is inside www/ui.js, which imports `@posit-dev/shinyreact`. With no
# server-side bundle there is also no #shinyreact-config tag and no protocol
# handshake; page_bare() is the one page entry point that emits neither.
ui <- page_bare(
page_react_dep("www", name = "npm-local"),
title = "Old Faithful"
)

server <- function(input, output, session) {
bins <- reactive(input$bins)

output$dist_data <- reactive_output({
n <- bins()
if (is.null(n)) {
return(NULL)
}
breaks <- seq(min(waiting), max(waiting), length.out = n + 1)
h <- hist(waiting, breaks = breaks, plot = FALSE)
# I() keeps length-1 vectors as JSON arrays (n = 1) instead of scalars.
list(breaks = I(h$breaks), counts = I(h$counts))
})

output$dist_caption <- reactive_output({
n <- bins()
if (is.null(n)) {
return(NULL)
}
paste0(
length(waiting),
" eruptions in ",
n,
" bin",
if (n == 1) "" else "s"
)
})
}

shinyApp(ui, server)
65 changes: 65 additions & 0 deletions examples/11-npm-local/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import csv
import math
import subprocess
import sys
from pathlib import Path

from shiny import App, Inputs, Outputs, Session
from shinyreact import page_bare, page_react_dep, reactive_output

_APP_DIR = Path(__file__).parent

# The bundle is gitignored, so a fresh clone has no `www/` at all. Build it on
# first run instead of greeting the reader with a stack trace — same as
# examples/09-hmr, the other npm-tier example.
if not (_APP_DIR / "www" / "ui.js").exists():
print("www/ui.js not found -- building the client bundle...", file=sys.stderr)
# `@posit-dev/shinyreact` is a `file:../../pkg-js` dep whose exports point
# at `dist-npm/`, which is not committed -- so the package has to be built
# before this app's own build can resolve it.
_pkg_js = _APP_DIR.parent.parent / "pkg-js"
if not (_pkg_js / "dist-npm").exists():
subprocess.run(["npm", "install"], cwd=_pkg_js, check=True)
subprocess.run(["npm", "run", "build"], cwd=_pkg_js, check=True)
subprocess.run(["npm", "install"], cwd=_APP_DIR, check=True)
subprocess.run(["npm", "run", "build"], cwd=_APP_DIR, check=True)

with (_APP_DIR / "faithful.csv").open(newline="") as f:
waiting = [float(row["waiting"]) for row in csv.DictReader(f)]


def histogram(values: list[float], bins: int) -> dict[str, list[float] | list[int]]:
"""Equal-width binning matching R's `hist()`: bins are (lo, hi], first inclusive."""
lo, hi = min(values), max(values)
width = (hi - lo) / bins
breaks = [lo + i * width for i in range(bins + 1)]
counts = [0] * bins
for v in values:
idx = math.ceil((v - lo) / width) - 1
counts[min(max(idx, 0), bins - 1)] += 1
return {"breaks": breaks, "counts": counts}


# The whole page: Shiny's own dependencies plus this app's bundle, served as
# an HTMLDependency out of www/. No shinyreact JS is injected — the client
# runtime is inside www/ui.js, which imports `@posit-dev/shinyreact`. With no
# server-side bundle there is also no #shinyreact-config tag and no protocol
# handshake; page_bare() is the one page entry point that emits neither.
ui = page_bare(
page_react_dep(src_dir=_APP_DIR / "www", name="npm-local"),
title="Old Faithful",
)


def server(input: Inputs, output: Outputs, session: Session):
@reactive_output
def dist_data():
return histogram(waiting, input.bins())

@reactive_output
def dist_caption():
n = input.bins()
return f"{len(waiting)} eruptions in {n} bin{'' if n == 1 else 's'}"


app = App(ui, server)
Loading