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
66 changes: 62 additions & 4 deletions .claude/skills/release-shinyreact/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ does **not** oblige releasing the others.
| Package | Version lives in | Tag | Automation |
|---|---|---|---|
| `shinyreact` (PyPI) | `pyproject.toml` (repo root) `version` | `py/v1.2.3` | `.github/workflows/release-py.yaml` |
| `@posit-dev/shinyreact` (npm) | `pkg-js/package.json` `version` | `js/v1.2.3` | `.github/workflows/release-js.yaml` |
| `@posit-dev/shinyreact` (npm) | `pkg-js/package.json` `version` | `js/v1.2.3` | `.github/workflows/release-js.yaml` — **stages only, a human approves** |
| `shinyreact` (R) | `pkg-r/DESCRIPTION` `Version` | `r/v1.2.3` | none — CRAN is a manual submission |

The `<lang>/v` prefix is what keeps the three apart; nothing else distinguishes
Expand Down Expand Up @@ -55,16 +55,70 @@ Trusted publishing must be configured once at

## JS → npm

1. Bump `version` in `pkg-js/package.json`. Open a PR, merge it.
Unlike PyPI, **CI cannot publish npm** — it can only stage. The tag kicks off a
build that uploads a pending tarball; a human then approves it with 2FA. Two
steps, and the second one is not yours to do.

1. Bump `version` in `pkg-js/package.json` (`cd pkg-js && npm version 1.2.3
--no-git-tag-version` also updates `package-lock.json`). Open a PR, merge it.
2. `git tag js/v1.2.3 && git push origin js/v1.2.3`.
3. `release-js.yaml` verifies the tag matches `package.json`, lints, tests,
builds (IIFE + npm ESM + types), and runs `npm publish --provenance`.
Requires the `NPM_TOKEN` repository secret.
builds (IIFE + npm ESM + types), and runs `npm stage publish`. Nothing is on
npm yet. The job summary ends with the approval commands.
4. **Hand off to the human.** Report the run URL and stop — do not try to
approve. Approval needs proof of presence (2FA), which an OIDC token cannot
provide:

```bash
npm stage list @posit-dev/shinyreact # find the stage id
npm stage view <stage-id> # inspect what CI built
npm stage download <stage-id> # or pull the actual tarball
npm stage approve <stage-id> --otp <code> # publishes it
npm stage reject <stage-id> # throws it away
```

The Staged Packages tab on <https://www.npmjs.com/package/@posit-dev/shinyreact>
does the same thing in a browser; 2FA is prompted either way. A stage is not
on the registry and nobody can install it, so a bad build is rejected rather
than unpublished — there is no 72-hour `npm unpublish` window to race.

There is **no `NPM_TOKEN`**, and adding one would be a regression — npm's own
guidance is "when trusted publishing is available for your workflow, always
prefer it over long-lived tokens", and direct publishing with a granular token
is being removed entirely in January 2027. Auth is npm trusted publishing
(OIDC): configured once on the package's Settings page against org
`posit-dev`, this repo, workflow filename `release-js.yaml`, and
`npm stage publish` as the *allowed action* — so the workflow cannot publish
directly even if someone edits it. Provenance is automatic under OIDC; the
workflow passes no `--provenance`.

Settings → Publishing access is also set to **"Require two-factor
authentication and disallow tokens"**. That closes the side door a token would
open without touching CI, since trusted publishing is OIDC rather than a
token. npm calls stage-only trusted publishing plus disallowed tokens the
maximum security posture; if you ever find yourself creating a token to work
around a release, that is the thing you are undoing.

Requires npm ≥ 11.15.0 and Node ≥ 22.14.0. `pkg-js/.nvmrc` pins Node 22, which
bundles npm 10.x, so the workflow upgrades npm explicitly — if a release fails
with an unknown `stage` command, that step is why.

A JS release is usually paired with a Python and R release, since the packages
ship the same bundle — but they are separate versions and separate tags. Do the
`make update-dist` PR first so all three ship the same code.

### The two things staging cannot do

- **A brand-new package cannot be staged**, and trusted publishing cannot be
configured until the package exists. `0.1.0` was therefore published by hand
from a maintainer's laptop — `npm login` and `npm publish --access public`,
interactively with 2FA, **no token created**. It has no `js/v0.1.0` tag
(pushing one would only have triggered a job that failed on a version already
taken) and no provenance attestation, since that requires a CI publish. Every
release from `0.1.1` on follows the flow above.
- **A new scope or a new package name** puts you back in that bootstrap case:
publish once interactively, then configure the trusted publisher.

## R → CRAN

No workflow. CRAN submission is interactive and cannot be automated by a tag.
Expand Down Expand Up @@ -95,3 +149,7 @@ produces the same tidyverse checklist.
Nothing is automated post-release. Check the artifact actually landed
(`pip index versions shinyreact`, `npm view @posit-dev/shinyreact version`) and
say so — a green workflow with a skipped publish step is the usual failure.

For npm, a green workflow means *staged*, not published: `npm view` will keep
reporting the previous version until a human runs `npm stage approve`. Don't
report a JS release as done on the strength of a green run.
53 changes: 42 additions & 11 deletions .github/workflows/release-js.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
name: release-js

# Publishes @posit-dev/shinyreact to npm. Trigger manually (workflow_dispatch) or
# by pushing a tag like js/v0.0.1 (the `js/` prefix distinguishes it from the
# Python and R release tags). Requires the NPM_TOKEN repository secret.
# Stages @posit-dev/shinyreact for npm. Trigger manually (workflow_dispatch) or
# by pushing a tag like js/v0.1.0 (the `js/` prefix distinguishes it from the
# Python and R release tags).
#
# This job does NOT publish. It runs `npm stage publish`, which uploads the
# tarball and leaves it pending until a human approves it — see the JS section
# of .claude/skills/release-shinyreact/SKILL.md. Approval requires proof of
# presence (2FA) and so cannot be done by CI.
#
# There is no NPM_TOKEN. Auth is npm trusted publishing (OIDC), configured at
# https://www.npmjs.com/package/@posit-dev/shinyreact/access against this
# workflow filename with `npm stage publish` as the allowed action. Provenance
# is automatic under OIDC; `--provenance` is not needed.
on:
workflow_dispatch:
push:
Expand All @@ -11,10 +21,10 @@ on:

permissions:
contents: read
id-token: write # npm provenance
id-token: write # npm trusted publishing (OIDC) + provenance

jobs:
publish:
stage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand All @@ -31,6 +41,11 @@ jobs:
node-version: "${{ steps.nvm.outputs.version }}"
registry-url: "https://registry.npmjs.org"

# Node 22 bundles npm 10.x; `npm stage publish` and OIDC auth need 11.15+.
# Pinned to the major so an npm 12 cannot break a release unannounced.
- name: 🆕 Upgrade npm
run: npm install -g npm@11

- name: 🔍 Check tag matches package version
if: startsWith(github.ref, 'refs/tags/')
working-directory: pkg-js
Expand All @@ -44,8 +59,6 @@ jobs:

- name: 🆙 Install dependencies
run: make js-setup-ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: 🧪 Lint and test
run: |
Expand All @@ -56,8 +69,26 @@ jobs:
working-directory: pkg-js
run: npm run build

- name: 🚀 Publish @posit-dev/shinyreact
- name: 🚀 Stage @posit-dev/shinyreact
working-directory: pkg-js
run: npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm stage publish

# `npm stage list` is not run here: only `npm stage publish` accepts an
# OIDC token, every other stage subcommand needs proof of presence.
- name: 📋 How to approve
run: |
{
echo "### Staged, not published"
echo
echo "Nothing is on npm yet, and nobody can install this. Approve or"
echo "reject it — 2FA is required, so CI cannot:"
echo
echo '```'
echo 'npm stage list @posit-dev/shinyreact'
echo 'npm stage view <stage-id>'
echo 'npm stage approve <stage-id> --otp <code> # publish it'
echo 'npm stage reject <stage-id> # throw it away'
echo '```'
echo
echo "Or the Staged Packages tab at <https://www.npmjs.com/package/@posit-dev/shinyreact>."
} >> "$GITHUB_STEP_SUMMARY"
117 changes: 117 additions & 0 deletions pkg-js/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# @posit-dev/shinyreact

<!-- badges: start -->

[![check-js](https://github.com/posit-dev/shinyreact/actions/workflows/check-js.yaml/badge.svg)](https://github.com/posit-dev/shinyreact/actions/workflows/check-js.yaml)
[![npm](https://img.shields.io/npm/v/@posit-dev/shinyreact)](https://www.npmjs.com/package/@posit-dev/shinyreact)
<!-- badges: end -->

React hooks and components for [Shiny](https://shiny.posit.co/). The Shiny server — [Python](https://posit-dev.github.io/shinyreact/py/) or [R](https://posit-dev.github.io/shinyreact/r/) — contains only reactive computation, and the UI is a React client you own. This package is the bridge, and it ships zero UI components: no buttons, no layout, no theme. You bring the component library.

One React client works against an `app.py` or an `app.R` server unchanged. The [full site](https://posit-dev.github.io/shinyreact/) covers all three packages.

## Do you need this package?

Only if you use a bundler. shinyreact apps come in two tiers:

- **No build step** — the server serves the same runtime as an IIFE and the client reads it off `window.shinyreact`. Nothing to install; you can stop reading.
- **Bundler** (Vite, esbuild, webpack) — `npm install` this package and `import` the hooks. You get types, tree-shaking, a development React with Fast Refresh, and your own dependency graph.

Both tiers are built from the same source and speak the same protocol version.

## Installation

```bash
npm install @posit-dev/shinyreact react react-dom
```

React 19 is a peer dependency, resolved by your bundler.

Then tell the page entry point that the client brings its own copy, so the server does not also serve the IIFE:

```python
set_react_page(shinyreact_js="client") # or page_react(), page_react_html(), ReactApp()
```

```r
page_react(shinyreact_js = "client")
```

Skip that and the app still works — the registries are page-scoped, so both copies share one set of inputs, outputs, and message handlers — but the page downloads and parses a second React for nothing. The bundle logs a `console.warn` when it detects this.

## Usage

```jsx
import { useShinyInput, useShinyOutputValue } from "@posit-dev/shinyreact";
import "@posit-dev/shinyreact/styles";
import { createRoot } from "react-dom/client";

function App() {
const [name, setName] = useShinyInput("name", "world");
const greeting = useShinyOutputValue("greeting");

return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<p>{greeting}</p>
</div>
);
}

// The page entry points emit no mount container, so make one.
createRoot(document.body.appendChild(document.createElement("div"))).render(
<App />,
);
```

`useShinyInput` writes to `input.name()` / `input$name` on the server; `useShinyOutputValue` reads whatever the matching `reactive_output` returned:

```python
@reactive_output
def greeting():
return f"Hello, {input.name()}!"
```

```r
output$greeting <- reactive_output({
paste0("Hello, ", input$name, "!")
})
```

The `"./styles"` export is only needed for components that ship CSS (`ImageOutput`'s placeholder spinner); the hooks have no styles of their own.

## API

| | |
| -------------- | ---------------------------------------------------------------------- |
| **Inputs** | `useShinyInput` · `useShinyInputValue` · `useSetShinyInput` |
| **Outputs** | `useShinyOutputValue` · `useShinyOutputStatus` · `useShinyOutputError` |
| **Messaging** | `useShinyMessageHandler` |
| **Session** | `useShinyInitialized` · `useShinyBusy` |
| **Components** | `ShinyOutput` · `ImageOutput` · `ShinyModuleProvider` |
| **Utilities** | `MISSING` · `PROTOCOL_VERSION` |

Pick the narrowest hook that fits the call site: a button that pushes events but never reads its own state wants `useSetShinyInput`, not `useShinyInput` with a discarded value.

`ShinyOutput` renders a traditional Shiny output element — a `shiny-data-frame`, a plotly widget — inside a React tree, and handles binding for you.

Full signatures and types: [JS API reference](https://posit-dev.github.io/shinyreact/js/).

## Try it

```bash
git clone https://github.com/posit-dev/shinyreact
cd shinyreact/examples/11-npm-local
shiny run app.py # builds the client bundle on first run
```

## Learn more

- [Get started](https://posit-dev.github.io/shinyreact/) walks through the `ui.tsx` pattern: inputs, outputs, messages, and embedding traditional Shiny renderers.
- [TSX files and JavaScript build tools](https://posit-dev.github.io/shinyreact/articles/tsx-and-build-tools.html) explains `.tsx`, JSX, TypeScript, and what `npm run build` does.
- [Client hooks](https://posit-dev.github.io/shinyreact/articles/hooks.html) documents every hook in detail.
- The [examples catalog](https://github.com/posit-dev/shinyreact/blob/main/examples/README.md) lists runnable apps from no-build to Vite + HMR.

## License

MIT © Posit Software, PBC
4 changes: 2 additions & 2 deletions pkg-js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion pkg-js/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
{
"name": "@posit-dev/shinyreact",
"version": "0.0.1",
"version": "0.1.1",
"description": "React hooks and components for Shiny (shinyreact) — the ui.tsx pattern's client runtime",
"license": "MIT",
"homepage": "https://posit-dev.github.io/shinyreact/js/",
"bugs": "https://github.com/posit-dev/shinyreact/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/posit-dev/shinyreact.git",
Expand Down