Skip to content

feat: shinyreact_js= switch for npm-tier pages; convert examples/09-hmr (#217) - #264

Merged
schloerke merged 6 commits into
mainfrom
schloerke/fix-issue-217
Aug 31, 2026
Merged

schloerke merged 6 commits into
mainfrom
schloerke/fix-issue-217

Conversation

@schloerke

Copy link
Copy Markdown
Collaborator

Closes #217.

Every page entry point injected the IIFE bundle unconditionally, so an app that
bundles @posit/shinyreact got two runtimes on the page.

The switch

shinyreact_js = "server" | "client", default "server" — who supplies
shinyreact.js (and shinyreact.css) to the page.

page_react(..., shinyreact_js="server")            # default
page_react_html(path, ..., shinyreact_js="server")
set_react_page(path=None, *, shinyreact_js="server")
ReactApp(server, ..., shinyreact_js="server")
page_react(..., shinyreact_js = "server")
page_react_html(path, extra_deps = NULL, shinyreact_js = "server")

"client" omits only those two files. The #shinyreact-config tag is
emitted either way, because the npm-tier client hard-errors without it.

Validation lives in one place per language (_serves_bundle() /
serves_bundle()), so every entry point rejects a typo identically.
set_react_page() and ReactApp() validate eagerly at call time rather
than at first page render, so a typo fails at app startup next to the line that
caused it:

ValueError: shinyreact_js='sever' is not valid. Expected one of 'server',
'client'. 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.

Why an enum, and why this name

include_runtime=False was the first shape. It lost on two counts: if the
bundler tier becomes the norm, a boolean is a permanent double negative at the
call site, and "runtime" is already overloaded in this repo — pkg-js/vite.config.npm.ts
externalizes react/jsx-runtime. An enum lets the default flip later with no
rename and no call-site churn, and leaves room for the decision record's
Option D (server-served ESM).

shinyreact_js names the artifact, so it does not promise the narrower thing
examples/04-shadcn/vite.config.js already controls with rollupOptions.external
(where React comes from). Names considered and dropped: react_runtime
(collides with React Server Components / react-dom/server), runtime_source
("runtime"), shinyreact_source (sits next to src_dir in the same signature),
shinyreact_dep / include_dep (a dep is a server-side htmltools object, so
="client" is a category error — and page_react() attaches four kinds of
dependency, page_react_html() already has extra_deps=, and R already has an
internal shinyreact_dep()).

What the server deliberately does not do

It never validates the choice, because 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 committed. A package.json-sniffing heuristic was
considered and rejected: it is wrong on 2 of the 3 examples that have one
(03-columns-shadcn and 04-shadcn have a build step and use the global), and
package.json is not part of a deployed app, so the same app would render one
way locally and another in production.

Double-load warning

Instead, the case that fails silently announces itself. From the npm entry:

[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().

console.warn, not a throw: nothing is broken (the registries are page-scoped,
so both copies share one set of inputs, outputs, and handlers) and killing a
working app over wasted bytes is the wrong trade. It lives in the npm entry
because only that build can observe the collision — deferred classic and module
scripts execute in document order and the page emits the bundle dep first, so
installGlobal() runs first and sees nothing.

The reverse mistake ("client" with a client that bundles nothing) needs no
help: it throws on the app's first hook call.

examples/09-hmr → the npm tier

  • imports @posit/shinyreact; src/shiny-bridge.{dev,prod}.ts, the
    resolve.alias switch, and the React-externalizing rollupOptions are gone
  • React is bundled in both modes — a development React in dev, which is what
    Fast Refresh requires and what the production-only React inside the IIFE could
    never provide
  • app.py uses set_react_page(shinyreact_js="client")
  • until the first npm publish the dep is file:../../pkg-js — repo-relative, so
    the lockfile stays portable ("resolved": "../../pkg-js", "link": true); build
    pkg-js before installing

Acceptance test from #217, on the real example page:

shinyreact.js served: False
shinyreact.css served: False
config tag:           True

and the built www/ui.js contains no window.shinyreact reference.

Verification

pytest 151 passed
testthat 246 passed
vitest 258 passed
pyright / tsc 0 errors
R CMD check 1 pre-existing NOTE (CRAN incoming feasibility)
ruff / air / prettier clean

New tests: omit-the-bundle for page_react, page_react_html, both
set_react_page() modes, and both ReactApp discovered modes; typo rejection
in both languages, cross-referenced by name; two entry-parity.test.ts cases
for the warning. That file's beforeEach also now deletes window.shinyreact —
installGlobal() was leaking it across tests, so what a test saw depended on
what ran before it.

Docs: FEATURES.md (shared shinyreact_js block, four updated signatures, the
third deliberate tier difference), both examples/09-hmr docs,
examples/README.md, the decision record, and the #217 justification comments
in message-registry.ts / react-registry.ts / CLAUDE.md, which all cited the
old flag when explaining why two copies can coexist.

pkg-py/tests/playwright/test_module_dependency.py is an unrelated ruff format
fixup that make py-format produced.

⚠️ Overlaps #263

#263 (examples/11-npm-local, evidence for #261) touches the same files and
conflicts semantically in pkg-js/src/npm.ts: it removes
requireShinyReactConfigTag(), while this PR keeps it and inserts the
double-load warning immediately above it. The two ideas are compatible — #263's
page_bare(page_react_dep(...)) page needs no switch, and this PR is what makes
page_react() / set_react_page() / ReactApp() / page_react_html() usable at
the npm tier — but they need sequencing, and whichever lands second will have to
resolve npm.ts, entry-parity.test.ts, FEATURES.md, CLAUDE.md,
decisions/2026-08-17-js-distribution.md, and examples/README.md.

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.
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.
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.
`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.
The only conflicts were the three generated bundles (pkg-js/dist,
pkg-py/src/shinyreact/www, pkg-r/inst/lib/shiny). Resolved by rebuilding from
the merged source with `make update-dist` rather than hand-merging minified JS;
all three are byte-identical afterwards.
@schloerke
schloerke merged commit 077b41a into main Aug 31, 2026
16 checks passed
@schloerke
schloerke deleted the schloerke/fix-issue-217 branch August 31, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npm tier: server-side switch to omit the IIFE bundle; convert examples/09-hmr to @posit/shinyreact

1 participant